thumbgate 1.4.0 → 1.4.1
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/.claude-plugin/README.md +25 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +195 -168
- package/adapters/chatgpt/INSTALL.md +59 -4
- package/bin/cli.js +4 -0
- package/config/github-about.json +1 -1
- package/package.json +9 -5
- package/public/index.html +44 -23
- package/scripts/auto-promote-gates.js +5 -3
- package/scripts/billing-setup.js +109 -0
- package/scripts/build-claude-mcpb.js +71 -5
- package/scripts/distribution-surfaces.js +28 -0
- package/scripts/feedback-to-rules.js +27 -8
- package/scripts/gates-engine.js +51 -7
- package/scripts/hosted-config.js +2 -0
- package/scripts/hybrid-feedback-context.js +26 -16
- package/scripts/operational-summary.js +41 -5
- package/scripts/ralph-loop.js +376 -0
- package/scripts/ralph-mode-ci.js +331 -0
- package/scripts/rotate-stripe-webhook-secret.js +314 -0
- package/src/api/server.js +23 -3
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ralph Mode CI — runs in GitHub Actions with secrets injected.
|
|
6
|
+
* Handles: X tweets, X mention replies, LinkedIn posts, GitHub issue monitoring,
|
|
7
|
+
* GitHub repo search + outreach, dev.to publishing, ThumbGate stats.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const https = require('https');
|
|
12
|
+
|
|
13
|
+
// ── Env ─────────────────────────────────────────────────────────────────
|
|
14
|
+
const X_API_KEY = process.env.X_API_KEY;
|
|
15
|
+
const X_API_SECRET = process.env.X_API_SECRET;
|
|
16
|
+
const X_ACCESS_TOKEN = process.env.X_ACCESS_TOKEN;
|
|
17
|
+
const X_ACCESS_TOKEN_SECRET = process.env.X_ACCESS_TOKEN_SECRET;
|
|
18
|
+
const X_BEARER_TOKEN = process.env.X_BEARER_TOKEN;
|
|
19
|
+
const LINKEDIN_ACCESS_TOKEN = process.env.LINKEDIN_ACCESS_TOKEN;
|
|
20
|
+
const LINKEDIN_PERSON_URN = process.env.LINKEDIN_PERSON_URN;
|
|
21
|
+
const DEVTO_API_KEY = process.env.DEVTO_API_KEY;
|
|
22
|
+
const GH_TOKEN = process.env.GH_TOKEN;
|
|
23
|
+
|
|
24
|
+
// ── X OAuth 1.0a ───────────────────────────────────────────────────────
|
|
25
|
+
function oauthSign(method, url, params, consumerSecret, tokenSecret) {
|
|
26
|
+
const sp = Object.keys(params).sort().map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])).join('&');
|
|
27
|
+
const bs = method + '&' + encodeURIComponent(url) + '&' + encodeURIComponent(sp);
|
|
28
|
+
return crypto.createHmac('sha1', encodeURIComponent(consumerSecret) + '&' + encodeURIComponent(tokenSecret)).update(bs).digest('base64');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function xAuthHeader(method, url) {
|
|
32
|
+
const p = {
|
|
33
|
+
oauth_consumer_key: X_API_KEY,
|
|
34
|
+
oauth_nonce: crypto.randomBytes(16).toString('hex'),
|
|
35
|
+
oauth_signature_method: 'HMAC-SHA1',
|
|
36
|
+
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
|
|
37
|
+
oauth_token: X_ACCESS_TOKEN,
|
|
38
|
+
oauth_version: '1.0',
|
|
39
|
+
};
|
|
40
|
+
p.oauth_signature = oauthSign(method, url, p, X_API_SECRET, X_ACCESS_TOKEN_SECRET);
|
|
41
|
+
return 'OAuth ' + Object.keys(p).sort().map(k => encodeURIComponent(k) + '="' + encodeURIComponent(p[k]) + '"').join(', ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
45
|
+
async function postTweet(text) {
|
|
46
|
+
const url = 'https://api.twitter.com/2/tweets';
|
|
47
|
+
const r = await fetch(url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { Authorization: xAuthHeader('POST', url), 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ text }),
|
|
51
|
+
});
|
|
52
|
+
const j = await r.json();
|
|
53
|
+
return { id: j.data?.id, error: j.detail };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function replyTweet(text, replyTo) {
|
|
57
|
+
const url = 'https://api.twitter.com/2/tweets';
|
|
58
|
+
const r = await fetch(url, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { Authorization: xAuthHeader('POST', url), 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ text, reply: { in_reply_to_tweet_id: replyTo } }),
|
|
62
|
+
});
|
|
63
|
+
const j = await r.json();
|
|
64
|
+
return { id: j.data?.id, error: j.detail };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function postLinkedIn(text) {
|
|
68
|
+
const r = await fetch('https://api.linkedin.com/v2/ugcPosts', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: 'Bearer ' + LINKEDIN_ACCESS_TOKEN,
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'X-Restli-Protocol-Version': '2.0.0',
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
author: LINKEDIN_PERSON_URN,
|
|
77
|
+
lifecycleState: 'PUBLISHED',
|
|
78
|
+
specificContent: {
|
|
79
|
+
'com.linkedin.ugc.ShareContent': {
|
|
80
|
+
shareCommentary: { text },
|
|
81
|
+
shareMediaCategory: 'NONE',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' },
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
const j = await r.json();
|
|
88
|
+
return { id: j.id, status: r.status };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function ghApi(endpoint) {
|
|
92
|
+
const r = await fetch('https://api.github.com' + endpoint, {
|
|
93
|
+
headers: { Authorization: 'token ' + GH_TOKEN, Accept: 'application/vnd.github+json' },
|
|
94
|
+
});
|
|
95
|
+
return r.json();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function ghPostComment(repo, issueNum, body) {
|
|
99
|
+
const r = await fetch(`https://api.github.com/repos/${repo}/issues/${issueNum}/comments`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: {
|
|
102
|
+
Authorization: 'token ' + GH_TOKEN,
|
|
103
|
+
Accept: 'application/vnd.github+json',
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({ body }),
|
|
107
|
+
});
|
|
108
|
+
return r.json();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── State file (persisted via git) ──────────────────────────────────────
|
|
112
|
+
const fs = require('fs');
|
|
113
|
+
const STATE_PATH = '.thumbgate/ralph-state.json';
|
|
114
|
+
|
|
115
|
+
function loadState() {
|
|
116
|
+
try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch { return {}; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function saveState(state) {
|
|
120
|
+
fs.mkdirSync('.thumbgate', { recursive: true });
|
|
121
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + '\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Tweet angles ────────────────────────────────────────────────────────
|
|
125
|
+
const TWEET_ANGLES = [
|
|
126
|
+
'Your CLAUDE.md has 50 rules. Your agent ignores half.\n\nThumbGate turns each into a PreToolUse gate — a physical block before the tool call executes.\n\nnpx thumbgate quick-start\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
127
|
+
'Self-distillation: your AI agent learns from its own mistakes.\n\n1. Agent runs tool call\n2. System checks outcome\n3. Failure → rule auto-generated\n4. Next session: gate blocks it\n\nZero human feedback needed.\n\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
128
|
+
'Thompson Sampling for AI agent gates:\n\nEach gate: Beta(alpha, beta)\nCorrect block → alpha++ → tighter\nFalse positive → beta++ → relaxes\n\nNo thresholds. Gates converge on their own.\n\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
129
|
+
'Google DeepMind: hidden prompt injections commandeer AI agents 86% of the time.\n\nThumbGate gates the action, not the prompt. PreToolUse hooks are the last defense.\n\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
130
|
+
'Every AI agent framework ships memory. None ship enforcement.\n\nMemory: "Don\'t force-push to main"\nEnforcement: *physically blocked*\n\nThumbGate is the enforcement layer.\n\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
131
|
+
'Founding Member: $49 once. ThumbGate Pro forever.\n\n50 spots. No subscription.\n\nSelf-distillation, SQL MCP gates, Thompson Sampling, context-stuffing, 68 tools on Smithery.\n\nhttps://buy.stripe.com/aFa4gz1M84r419v7mb3sI05',
|
|
132
|
+
'Context-stuffing: skip RAG entirely.\n\nDump ALL prevention rules into agent context at session start. 20-200 rules = 1K-10K tokens.\n\nInspired by Karpathy. Simpler. Faster.\n\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
133
|
+
'The AI agent safety stack:\n\nGovernance: Paperclip\nOrchestration: iloom\nContext: RepoWise\nEnforcement: ThumbGate\n\nAll open source. All necessary.\n\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// ── GitHub issues to monitor ────────────────────────────────────────────
|
|
137
|
+
const WATCHED_ISSUES = [
|
|
138
|
+
{ repo: 'leogodin217/leos_claude_starter', num: 1 },
|
|
139
|
+
{ repo: 'RepoWise/backend', num: 34 },
|
|
140
|
+
{ repo: 'ScaleLeanChris/paperclip-ing', num: 1 },
|
|
141
|
+
{ repo: 'sd0xdev/sd0x-dev-flow', num: 5 },
|
|
142
|
+
{ repo: 'logi-cmd/agent-guardrails', num: 3 },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// ── Main ────────────────────────────────────────────────────────────────
|
|
146
|
+
async function main() {
|
|
147
|
+
console.log('=== RALPH MODE CI — ' + new Date().toISOString() + ' ===\n');
|
|
148
|
+
const state = loadState();
|
|
149
|
+
const report = { tweets: 0, replies: 0, linkedin: 0, ghIssues: 0, ghOutreach: 0 };
|
|
150
|
+
|
|
151
|
+
// ── 1. Check X mentions and reply ──
|
|
152
|
+
if (X_BEARER_TOKEN && X_API_KEY) {
|
|
153
|
+
try {
|
|
154
|
+
const bearer = decodeURIComponent(X_BEARER_TOKEN);
|
|
155
|
+
const mentionsRes = await fetch(
|
|
156
|
+
'https://api.twitter.com/2/tweets/search/recent?query=(@IgorGanapolsky OR thumbgate) -from:IgorGanapolsky&max_results=20&tweet.fields=author_id,text,created_at,id&expansions=author_id&user.fields=username',
|
|
157
|
+
{ headers: { Authorization: 'Bearer ' + bearer } }
|
|
158
|
+
).then(r => r.json());
|
|
159
|
+
|
|
160
|
+
const mu = {};
|
|
161
|
+
(mentionsRes.includes?.users || []).forEach(u => mu[u.id] = u);
|
|
162
|
+
const lastChecked = state.lastMentionCheck || '2026-04-01T00:00:00Z';
|
|
163
|
+
const newMentions = (mentionsRes.data || []).filter(
|
|
164
|
+
t => new Date(t.created_at) > new Date(lastChecked) && (mu[t.author_id] || {}).username !== 'IgorGanapolsky'
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
console.log('X mentions since last check: ' + newMentions.length);
|
|
168
|
+
|
|
169
|
+
for (const t of newMentions.slice(0, 5)) {
|
|
170
|
+
const u = mu[t.author_id] || {};
|
|
171
|
+
const replyText = '@' + u.username + ' ThumbGate: PreToolUse enforcement for AI agents. Thompson Sampling adapts confidence. 68 tools on Smithery.\n\nhttps://github.com/IgorGanapolsky/ThumbGate';
|
|
172
|
+
const r = await replyTweet(replyText, t.id);
|
|
173
|
+
console.log(' Replied to @' + u.username + ': ' + (r.id || r.error));
|
|
174
|
+
report.replies++;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
state.lastMentionCheck = new Date().toISOString();
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.log('X mentions error: ' + e.message);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── 2. Post new tweet ──
|
|
183
|
+
try {
|
|
184
|
+
const angleIndex = Math.floor(Date.now() / 7200000) % TWEET_ANGLES.length;
|
|
185
|
+
const r = await postTweet(TWEET_ANGLES[angleIndex]);
|
|
186
|
+
console.log('Tweet posted: ' + (r.id || r.error));
|
|
187
|
+
report.tweets++;
|
|
188
|
+
} catch (e) {
|
|
189
|
+
console.log('Tweet error: ' + e.message);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
console.log('X: skipped (no API keys)');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── 3. LinkedIn post ──
|
|
196
|
+
if (LINKEDIN_ACCESS_TOKEN && LINKEDIN_PERSON_URN) {
|
|
197
|
+
try {
|
|
198
|
+
const lastLinkedin = state.lastLinkedinPost || '2026-04-01T00:00:00Z';
|
|
199
|
+
const hoursSince = (Date.now() - new Date(lastLinkedin).getTime()) / 3600000;
|
|
200
|
+
if (hoursSince >= 4) {
|
|
201
|
+
const angles = [
|
|
202
|
+
'ThumbGate: pre-action gates for AI coding agents. 68 tools on Smithery. Works with Claude Code, Cursor, Codex, Gemini, Amp.\n\nnpx thumbgate quick-start\n\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
203
|
+
'Every AI agent framework ships memory. None ship enforcement.\n\nThumbGate adds PreToolUse hooks that block bad actions before execution. Thompson Sampling adapts. Self-distillation auto-learns.\n\nhttps://github.com/IgorGanapolsky/ThumbGate',
|
|
204
|
+
];
|
|
205
|
+
const r = await postLinkedIn(angles[Math.floor(Date.now() / 14400000) % angles.length]);
|
|
206
|
+
console.log('LinkedIn posted: ' + (r.id || r.status));
|
|
207
|
+
state.lastLinkedinPost = new Date().toISOString();
|
|
208
|
+
report.linkedin++;
|
|
209
|
+
} else {
|
|
210
|
+
console.log('LinkedIn: skipped (' + Math.round(4 - hoursSince) + 'hr until next)');
|
|
211
|
+
}
|
|
212
|
+
} catch (e) {
|
|
213
|
+
console.log('LinkedIn error: ' + e.message);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
console.log('LinkedIn: skipped (no token)');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── 4. GitHub issue monitoring ──
|
|
220
|
+
if (GH_TOKEN) {
|
|
221
|
+
const knownComments = state.issueComments || {};
|
|
222
|
+
|
|
223
|
+
for (const { repo, num } of WATCHED_ISSUES) {
|
|
224
|
+
try {
|
|
225
|
+
const issue = await ghApi('/repos/' + repo + '/issues/' + num);
|
|
226
|
+
const key = repo + '#' + num;
|
|
227
|
+
const prev = knownComments[key] || 0;
|
|
228
|
+
|
|
229
|
+
if (issue.comments > prev) {
|
|
230
|
+
console.log(key + ': ' + (issue.comments - prev) + ' new comment(s)');
|
|
231
|
+
|
|
232
|
+
// Read latest comment
|
|
233
|
+
const comments = await ghApi('/repos/' + repo + '/issues/' + num + '/comments?per_page=1&page=' + issue.comments);
|
|
234
|
+
const latest = comments[0];
|
|
235
|
+
if (latest && latest.user.login !== 'IgorGanapolsky') {
|
|
236
|
+
const reply = 'Thanks for the response! ThumbGate adds PreToolUse enforcement — gates that block known-bad actions before execution. Thompson Sampling adapts confidence. Self-distillation auto-generates rules from outcomes.\n\n68 tools on [Smithery](https://smithery.ai/servers/rlhf-loop/thumbgate). Would love to explore integration.\n\nhttps://github.com/IgorGanapolsky/ThumbGate';
|
|
237
|
+
await ghPostComment(repo, num, reply);
|
|
238
|
+
console.log(' Replied to @' + latest.user.login);
|
|
239
|
+
report.ghIssues++;
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
console.log(key + ': no new comments');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
knownComments[key] = issue.comments;
|
|
246
|
+
} catch (e) {
|
|
247
|
+
console.log(repo + '#' + num + ' error: ' + e.message);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── 5. Search for new repos ──
|
|
252
|
+
try {
|
|
253
|
+
const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
|
|
254
|
+
const search = await ghApi('/search/repositories?q=agent+safety+OR+pretooluse+OR+claude+code+hooks+OR+mcp+gate+pushed:>' + weekAgo + '&sort=stars&order=desc&per_page=5');
|
|
255
|
+
const contacted = new Set(state.contactedRepos || []);
|
|
256
|
+
let opened = 0;
|
|
257
|
+
|
|
258
|
+
for (const repo of (search.items || [])) {
|
|
259
|
+
if (opened >= 2) break;
|
|
260
|
+
if (repo.stargazers_count < 3) continue;
|
|
261
|
+
if (contacted.has(repo.full_name)) continue;
|
|
262
|
+
if (repo.full_name.includes('IgorGanapolsky')) continue;
|
|
263
|
+
|
|
264
|
+
const body = 'Hey — noticed you\'re building in the AI agent safety space. [ThumbGate](https://github.com/IgorGanapolsky/ThumbGate) adds PreToolUse hooks that block known-bad actions before execution, with Thompson Sampling for adaptive gate confidence and self-distillation for auto-learning from outcomes.\n\n68 tools on [Smithery](https://smithery.ai/servers/rlhf-loop/thumbgate). Could be complementary — would love to explore integration.\n\nMIT licensed, free tier available.';
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await fetch('https://api.github.com/repos/' + repo.full_name + '/issues', {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: {
|
|
270
|
+
Authorization: 'token ' + GH_TOKEN,
|
|
271
|
+
Accept: 'application/vnd.github+json',
|
|
272
|
+
'Content-Type': 'application/json',
|
|
273
|
+
},
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
title: 'Integration: ThumbGate enforcement layer for ' + repo.name,
|
|
276
|
+
body: body,
|
|
277
|
+
}),
|
|
278
|
+
});
|
|
279
|
+
console.log('Opened issue on ' + repo.full_name + ' (stars=' + repo.stargazers_count + ')');
|
|
280
|
+
contacted.add(repo.full_name);
|
|
281
|
+
opened++;
|
|
282
|
+
report.ghOutreach++;
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.log('Issue creation failed on ' + repo.full_name + ': ' + e.message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
state.contactedRepos = [...contacted];
|
|
289
|
+
} catch (e) {
|
|
290
|
+
console.log('Repo search error: ' + e.message);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── 6. ThumbGate stats ──
|
|
294
|
+
try {
|
|
295
|
+
const repo = await ghApi('/repos/IgorGanapolsky/ThumbGate');
|
|
296
|
+
console.log('ThumbGate: stars=' + repo.stargazers_count + ' forks=' + repo.forks_count);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.log('Stats error: ' + e.message);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── 7. awesome-mcp PR ──
|
|
302
|
+
try {
|
|
303
|
+
const pr = await ghApi('/repos/punkpeye/awesome-mcp-servers/pulls/4474');
|
|
304
|
+
console.log('awesome-mcp#4474: state=' + pr.state + ' merged=' + pr.merged);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
console.log('PR check error: ' + e.message);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
state.issueComments = knownComments;
|
|
310
|
+
} else {
|
|
311
|
+
console.log('GitHub: skipped (no token)');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Save state ──
|
|
315
|
+
state.lastRun = new Date().toISOString();
|
|
316
|
+
saveState(state);
|
|
317
|
+
|
|
318
|
+
// ── Report ──
|
|
319
|
+
console.log('\n=== REPORT ===');
|
|
320
|
+
console.log('Tweets: ' + report.tweets);
|
|
321
|
+
console.log('Replies: ' + report.replies);
|
|
322
|
+
console.log('LinkedIn: ' + report.linkedin);
|
|
323
|
+
console.log('GitHub issues responded: ' + report.ghIssues);
|
|
324
|
+
console.log('GitHub outreach opened: ' + report.ghOutreach);
|
|
325
|
+
console.log('=== DONE ===');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
main().catch(e => {
|
|
329
|
+
console.error('Ralph Mode CI fatal error:', e.message);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const https = require('node:https');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_ENDPOINT_URL = 'https://thumbgate-production.up.railway.app/v1/billing/webhook';
|
|
10
|
+
const REQUIRED_EVENTS = ['checkout.session.completed', 'customer.subscription.deleted'];
|
|
11
|
+
const FIXED_GH_BINARIES = ['/usr/bin/gh', '/usr/local/bin/gh', '/opt/homebrew/bin/gh'];
|
|
12
|
+
const SECRET_PATTERN = /\b(?:sk|rk)_(?:live|test)_\w+|\bwhsec_\w+/g;
|
|
13
|
+
|
|
14
|
+
function redact(value) {
|
|
15
|
+
return String(value || '').replaceAll(SECRET_PATTERN, '[REDACTED]');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function encodeForm(params) {
|
|
19
|
+
const pairs = [];
|
|
20
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
for (const item of value) {
|
|
23
|
+
const arrayKey = `${key}[]`;
|
|
24
|
+
pairs.push(`${encodeURIComponent(arrayKey)}=${encodeURIComponent(String(item))}`);
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (value !== undefined && value !== null) {
|
|
29
|
+
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return pairs.join('&');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertLiveStripeKey(apiKey, requireLive = true) {
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
throw new Error('STRIPE_SECRET_KEY is required.');
|
|
38
|
+
}
|
|
39
|
+
if (requireLive && !/^(sk|rk)_live_/.test(apiKey)) {
|
|
40
|
+
throw new Error('Refusing to rotate production webhook with a non-live Stripe key.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stripeRequest({ method = 'GET', path, apiKey, body, request = https.request }) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const payload = body ? encodeForm(body) : '';
|
|
47
|
+
const req = request({
|
|
48
|
+
hostname: 'api.stripe.com',
|
|
49
|
+
path,
|
|
50
|
+
method,
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${apiKey}`,
|
|
53
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
54
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
55
|
+
},
|
|
56
|
+
}, (res) => {
|
|
57
|
+
let raw = '';
|
|
58
|
+
res.setEncoding('utf8');
|
|
59
|
+
res.on('data', (chunk) => { raw += chunk; });
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
let parsed = {};
|
|
62
|
+
try {
|
|
63
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
64
|
+
} catch {
|
|
65
|
+
reject(new Error(`Stripe returned non-JSON response (${res.statusCode}): ${redact(raw)}`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
69
|
+
const message = parsed.error?.message ? parsed.error.message : raw;
|
|
70
|
+
reject(new Error(`Stripe API ${method} ${path} failed (${res.statusCode}): ${redact(message)}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
resolve(parsed);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
req.on('error', reject);
|
|
77
|
+
req.end(payload);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function listWebhookEndpoints(apiKey, options = {}) {
|
|
82
|
+
const requestStripe = options.stripeRequest || stripeRequest;
|
|
83
|
+
const endpoints = [];
|
|
84
|
+
let startingAfter = '';
|
|
85
|
+
for (;;) {
|
|
86
|
+
const suffix = startingAfter
|
|
87
|
+
? `&starting_after=${encodeURIComponent(startingAfter)}`
|
|
88
|
+
: '';
|
|
89
|
+
const response = await requestStripe({
|
|
90
|
+
apiKey,
|
|
91
|
+
path: `/v1/webhook_endpoints?limit=100${suffix}`,
|
|
92
|
+
});
|
|
93
|
+
endpoints.push(...(Array.isArray(response.data) ? response.data : []));
|
|
94
|
+
if (!response.has_more || endpoints.length === 0) {
|
|
95
|
+
return endpoints;
|
|
96
|
+
}
|
|
97
|
+
startingAfter = endpoints.at(-1).id;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function createWebhookEndpoint({ apiKey, endpointUrl, timestamp, stripeRequest: requestStripe = stripeRequest }) {
|
|
102
|
+
const endpoint = await requestStripe({
|
|
103
|
+
method: 'POST',
|
|
104
|
+
path: '/v1/webhook_endpoints',
|
|
105
|
+
apiKey,
|
|
106
|
+
body: {
|
|
107
|
+
url: endpointUrl,
|
|
108
|
+
enabled_events: REQUIRED_EVENTS,
|
|
109
|
+
description: `ThumbGate billing webhook rotated ${timestamp}`,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (!endpoint.id || !endpoint.secret) {
|
|
113
|
+
throw new Error('Stripe webhook endpoint creation did not return both id and signing secret.');
|
|
114
|
+
}
|
|
115
|
+
return endpoint;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function disableWebhookEndpoint({ apiKey, endpointId, stripeRequest: requestStripe = stripeRequest }) {
|
|
119
|
+
return requestStripe({
|
|
120
|
+
method: 'POST',
|
|
121
|
+
path: `/v1/webhook_endpoints/${encodeURIComponent(endpointId)}`,
|
|
122
|
+
apiKey,
|
|
123
|
+
body: { disabled: true },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveGhBinary(options = {}) {
|
|
128
|
+
const accessSync = options.accessSync || fs.accessSync;
|
|
129
|
+
const candidates = options.candidates || FIXED_GH_BINARIES;
|
|
130
|
+
|
|
131
|
+
for (const candidate of candidates) {
|
|
132
|
+
try {
|
|
133
|
+
accessSync(candidate, fs.constants.X_OK);
|
|
134
|
+
return candidate;
|
|
135
|
+
} catch {
|
|
136
|
+
// Try the next fixed, system-owned path.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw new Error(`Unable to locate GH CLI in fixed paths: ${candidates.join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function runGh(args, { token, input, ghBinary, accessSync, spawnSyncImpl = spawnSync } = {}) {
|
|
144
|
+
const result = spawnSyncImpl(ghBinary || resolveGhBinary({ accessSync }), args, {
|
|
145
|
+
input,
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
env: {
|
|
148
|
+
...process.env,
|
|
149
|
+
GH_TOKEN: token || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
if (result.status !== 0) {
|
|
153
|
+
throw new Error(`gh ${args.join(' ')} failed: ${redact(result.stderr || result.stdout)}`);
|
|
154
|
+
}
|
|
155
|
+
return result.stdout.trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getSecretUpdatedAt({ repo, token, secretName, runner = runGh }) {
|
|
159
|
+
return runner([
|
|
160
|
+
'api',
|
|
161
|
+
`repos/${repo}/actions/secrets/${secretName}`,
|
|
162
|
+
'--jq',
|
|
163
|
+
'.updated_at',
|
|
164
|
+
], { token });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setGithubSecret({ repo, token, name, value, runner = runGh }) {
|
|
168
|
+
runner(['secret', 'set', name, '--repo', repo], { token, input: value });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function setGithubVariable({ repo, token, name, value, runner = runGh }) {
|
|
172
|
+
runner(['variable', 'set', name, '--repo', repo, '--body', value], { token });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findSameUrlEndpoints(endpoints, endpointUrl, excludeId) {
|
|
176
|
+
return endpoints.filter((endpoint) => endpoint?.id
|
|
177
|
+
&& endpoint.id !== excludeId
|
|
178
|
+
&& endpoint?.url === endpointUrl
|
|
179
|
+
&& endpoint?.status !== 'disabled');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveRequireLiveStripeKey(options) {
|
|
183
|
+
if (Object.hasOwn(options, 'requireLive')) {
|
|
184
|
+
return options.requireLive;
|
|
185
|
+
}
|
|
186
|
+
const envModes = {
|
|
187
|
+
false: false,
|
|
188
|
+
true: true,
|
|
189
|
+
};
|
|
190
|
+
return envModes[process.env.REQUIRE_LIVE_STRIPE_KEY] ?? true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function rotateStripeWebhookSecret(options = {}) {
|
|
194
|
+
const endpointUrl = options.endpointUrl || process.env.STRIPE_WEBHOOK_ENDPOINT_URL || DEFAULT_ENDPOINT_URL;
|
|
195
|
+
const repo = Object.hasOwn(options, 'repo') ? options.repo : process.env.GITHUB_REPOSITORY;
|
|
196
|
+
const stripeKey = options.stripeKey || process.env.STRIPE_SECRET_KEY;
|
|
197
|
+
const githubToken = options.githubToken || process.env.GH_ADMIN_TOKEN || process.env.THUMBGATE_MAINTENANCE_GH_TOKEN;
|
|
198
|
+
const timestamp = options.timestamp || new Date().toISOString();
|
|
199
|
+
const requireLive = resolveRequireLiveStripeKey(options);
|
|
200
|
+
const dryRun = options.dryRun === true || process.env.DRY_RUN === 'true';
|
|
201
|
+
const stripe = {
|
|
202
|
+
listWebhookEndpoints: options.listWebhookEndpoints || listWebhookEndpoints,
|
|
203
|
+
createWebhookEndpoint: options.createWebhookEndpoint || createWebhookEndpoint,
|
|
204
|
+
disableWebhookEndpoint: options.disableWebhookEndpoint || disableWebhookEndpoint,
|
|
205
|
+
};
|
|
206
|
+
const github = {
|
|
207
|
+
getSecretUpdatedAt: options.getSecretUpdatedAt || getSecretUpdatedAt,
|
|
208
|
+
setGithubSecret: options.setGithubSecret || setGithubSecret,
|
|
209
|
+
setGithubVariable: options.setGithubVariable || setGithubVariable,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
assertLiveStripeKey(stripeKey, requireLive);
|
|
213
|
+
if (!repo) {
|
|
214
|
+
throw new Error('GITHUB_REPOSITORY is required.');
|
|
215
|
+
}
|
|
216
|
+
if (dryRun || githubToken) {
|
|
217
|
+
// Dry runs only need Stripe read access; real rotations also need GitHub secret write access.
|
|
218
|
+
} else {
|
|
219
|
+
throw new Error('THUMBGATE_MAINTENANCE_GH_TOKEN is required to update GitHub Secrets and Variables.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const before = await stripe.listWebhookEndpoints(stripeKey);
|
|
223
|
+
const replacementCandidates = findSameUrlEndpoints(before, endpointUrl);
|
|
224
|
+
if (dryRun) {
|
|
225
|
+
return {
|
|
226
|
+
dryRun: true,
|
|
227
|
+
endpointUrl,
|
|
228
|
+
matchingEnabledEndpoints: replacementCandidates.map((endpoint) => endpoint.id),
|
|
229
|
+
requiredEvents: REQUIRED_EVENTS,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const endpoint = await stripe.createWebhookEndpoint({ apiKey: stripeKey, endpointUrl, timestamp });
|
|
234
|
+
github.setGithubSecret({
|
|
235
|
+
repo,
|
|
236
|
+
token: githubToken,
|
|
237
|
+
name: 'STRIPE_WEBHOOK_SECRET',
|
|
238
|
+
value: endpoint.secret,
|
|
239
|
+
});
|
|
240
|
+
github.setGithubVariable({
|
|
241
|
+
repo,
|
|
242
|
+
token: githubToken,
|
|
243
|
+
name: 'STRIPE_WEBHOOK_SECRET_ROTATED_AT',
|
|
244
|
+
value: timestamp,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const stripeSecretUpdatedAt = github.getSecretUpdatedAt({
|
|
248
|
+
repo,
|
|
249
|
+
token: githubToken,
|
|
250
|
+
secretName: 'STRIPE_SECRET_KEY',
|
|
251
|
+
});
|
|
252
|
+
if (stripeSecretUpdatedAt) {
|
|
253
|
+
github.setGithubVariable({
|
|
254
|
+
repo,
|
|
255
|
+
token: githubToken,
|
|
256
|
+
name: 'STRIPE_SECRET_KEY_ROTATED_AT',
|
|
257
|
+
value: stripeSecretUpdatedAt,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const disabledEndpointIds = [];
|
|
262
|
+
for (const oldEndpoint of findSameUrlEndpoints(before, endpointUrl, endpoint.id)) {
|
|
263
|
+
await stripe.disableWebhookEndpoint({ apiKey: stripeKey, endpointId: oldEndpoint.id });
|
|
264
|
+
disabledEndpointIds.push(oldEndpoint.id);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
dryRun: false,
|
|
269
|
+
endpointUrl,
|
|
270
|
+
newEndpointId: endpoint.id,
|
|
271
|
+
disabledEndpointIds,
|
|
272
|
+
requiredEvents: REQUIRED_EVENTS,
|
|
273
|
+
rotatedAt: timestamp,
|
|
274
|
+
stripeSecretKeyRotatedAt: stripeSecretUpdatedAt || null,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function main() {
|
|
279
|
+
try {
|
|
280
|
+
const result = await rotateStripeWebhookSecret();
|
|
281
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
process.stderr.write(`${redact(err?.message ? err.message : err)}\n`);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isCliInvocation(argv = process.argv) {
|
|
289
|
+
return path.resolve(argv[1] || '') === __filename;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (isCliInvocation()) {
|
|
293
|
+
main();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
DEFAULT_ENDPOINT_URL,
|
|
298
|
+
REQUIRED_EVENTS,
|
|
299
|
+
assertLiveStripeKey,
|
|
300
|
+
createWebhookEndpoint,
|
|
301
|
+
disableWebhookEndpoint,
|
|
302
|
+
encodeForm,
|
|
303
|
+
findSameUrlEndpoints,
|
|
304
|
+
getSecretUpdatedAt,
|
|
305
|
+
listWebhookEndpoints,
|
|
306
|
+
redact,
|
|
307
|
+
resolveGhBinary,
|
|
308
|
+
resolveRequireLiveStripeKey,
|
|
309
|
+
rotateStripeWebhookSecret,
|
|
310
|
+
runGh,
|
|
311
|
+
setGithubSecret,
|
|
312
|
+
setGithubVariable,
|
|
313
|
+
stripeRequest,
|
|
314
|
+
};
|