@kylindc/ccxray 1.2.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 +39 -0
- package/LICENSE +21 -0
- package/README.ja.md +144 -0
- package/README.md +145 -0
- package/README.zh-TW.md +144 -0
- package/package.json +46 -0
- package/public/app.js +99 -0
- package/public/cost-budget-ui.js +305 -0
- package/public/entry-rendering.js +535 -0
- package/public/index.html +119 -0
- package/public/intercept-ui.js +335 -0
- package/public/keyboard-nav.js +208 -0
- package/public/messages.js +750 -0
- package/public/miller-columns.js +1686 -0
- package/public/quota-ticker.js +67 -0
- package/public/style.css +431 -0
- package/public/system-prompt-ui.js +327 -0
- package/server/auth.js +34 -0
- package/server/bedrock-credentials.js +141 -0
- package/server/config.js +190 -0
- package/server/cost-budget.js +220 -0
- package/server/cost-worker.js +110 -0
- package/server/eventstream.js +148 -0
- package/server/forward.js +683 -0
- package/server/helpers.js +393 -0
- package/server/hub.js +418 -0
- package/server/index.js +551 -0
- package/server/pricing.js +133 -0
- package/server/restore.js +141 -0
- package/server/routes/api.js +123 -0
- package/server/routes/costs.js +124 -0
- package/server/routes/intercept.js +89 -0
- package/server/routes/sse.js +44 -0
- package/server/sigv4.js +104 -0
- package/server/sse-broadcast.js +71 -0
- package/server/storage/index.js +36 -0
- package/server/storage/interface.js +26 -0
- package/server/storage/local.js +79 -0
- package/server/storage/s3.js +91 -0
- package/server/store.js +108 -0
- package/server/system-prompt.js +150 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// ── System Prompt Changelog ─────────────────────────────────────────────
|
|
2
|
+
let spVersions = []; // all claude-code versions from API
|
|
3
|
+
let spSelectedIdx = 0; // index into spVersions
|
|
4
|
+
let spMode = 'content'; // 'content' or 'diff'
|
|
5
|
+
let hideMinorEdit = false;
|
|
6
|
+
let currentHunkIdx = 0;
|
|
7
|
+
|
|
8
|
+
function updateSysPromptBadge() {
|
|
9
|
+
const badge = document.getElementById('sysprompt-badge');
|
|
10
|
+
if (!badge) return;
|
|
11
|
+
fetch('/_api/sysprompt/versions').then(r => r.json()).then(data => {
|
|
12
|
+
const versions = (data.versions || []).filter(v => v.agentKey === 'claude-code');
|
|
13
|
+
if (!versions.length) return;
|
|
14
|
+
const latest = versions[0].version;
|
|
15
|
+
const lastSeen = localStorage.getItem('sysprompt_last_seen');
|
|
16
|
+
badge.style.display = lastSeen !== latest ? 'block' : 'none';
|
|
17
|
+
}).catch(() => {});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function openSystemPromptPanel(forceDiff) {
|
|
21
|
+
// If called from outside tab system, redirect to tab
|
|
22
|
+
if (typeof activeTab !== 'undefined' && activeTab !== 'sysprompt') {
|
|
23
|
+
switchTab('sysprompt', forceDiff);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const badge = document.getElementById('sysprompt-badge');
|
|
27
|
+
const hasBadge = forceDiff || (badge && badge.style.display !== 'none');
|
|
28
|
+
|
|
29
|
+
document.getElementById('diff-overlay').classList.add('open');
|
|
30
|
+
const panel = document.getElementById('diff-text-panel');
|
|
31
|
+
if (panel) panel.innerHTML = '<div style="color:var(--dim);font-size:11px">Loading...</div>';
|
|
32
|
+
|
|
33
|
+
const data = await fetch('/_api/sysprompt/versions').then(r => r.json());
|
|
34
|
+
spVersions = (data.versions || []).filter(v => v.agentKey === 'claude-code');
|
|
35
|
+
|
|
36
|
+
const latest = spVersions[0]?.version;
|
|
37
|
+
if (latest) localStorage.setItem('sysprompt_last_seen', latest);
|
|
38
|
+
if (badge) badge.style.display = 'none';
|
|
39
|
+
|
|
40
|
+
if (!spVersions.length) {
|
|
41
|
+
if (panel) panel.innerHTML = '<div style="color:var(--dim);font-size:11px">No versions found.</div>';
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
spSelectedIdx = 0;
|
|
46
|
+
spMode = hasBadge ? 'diff' : 'content';
|
|
47
|
+
renderVersionList();
|
|
48
|
+
loadSelectedVersion();
|
|
49
|
+
|
|
50
|
+
// Update Row 2 context
|
|
51
|
+
const row2 = document.getElementById('row2-sp-version');
|
|
52
|
+
if (row2 && latest) {
|
|
53
|
+
row2.textContent = 'v' + latest + ' (latest) · ' + spVersions.length + ' versions · ' + (spMode === 'diff' ? 'DIFF' : 'CONTENT') + ' mode';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function closeDiffPanel() {
|
|
58
|
+
const body = document.querySelector('.sp-changelog-body');
|
|
59
|
+
if (body) body.classList.remove('sp-mobile-detail');
|
|
60
|
+
updateBackButton(false);
|
|
61
|
+
switchTab('dashboard');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Version list ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function renderVersionList() {
|
|
67
|
+
const container = document.getElementById('sp-version-list');
|
|
68
|
+
if (!container) return;
|
|
69
|
+
let html = '<div class="sp-version-list-title">Versions</div>';
|
|
70
|
+
for (let i = 0; i < spVersions.length; i++) {
|
|
71
|
+
const v = spVersions[i];
|
|
72
|
+
const size = v.coreLen ? (v.coreLen / 1000).toFixed(1) + 'k' : '';
|
|
73
|
+
const next = spVersions[i + 1];
|
|
74
|
+
let delta = '';
|
|
75
|
+
if (v.coreLen && next?.coreLen && v.coreLen !== next.coreLen) {
|
|
76
|
+
const diff = (v.coreLen - next.coreLen) / 1000;
|
|
77
|
+
const sign = diff > 0 ? '+' : '';
|
|
78
|
+
const color = diff > 0 ? 'var(--green)' : 'var(--red)';
|
|
79
|
+
delta = `<span style="color:${color}">${sign}${diff.toFixed(1)}k</span>`;
|
|
80
|
+
}
|
|
81
|
+
const isActive = i === spSelectedIdx;
|
|
82
|
+
let rowBg = '';
|
|
83
|
+
if (v.coreLen && next?.coreLen && v.coreLen !== next.coreLen) {
|
|
84
|
+
rowBg = (v.coreLen - next.coreLen) > 0 ? 'background:rgba(46,160,67,0.08)' : 'background:rgba(248,81,73,0.08)';
|
|
85
|
+
}
|
|
86
|
+
html += `<div class="sp-version-item${isActive ? ' active' : ''}" data-idx="${i}" onclick="selectVersion(${i})" style="${rowBg}">`;
|
|
87
|
+
const date = (v.firstSeen || '').slice(5);
|
|
88
|
+
html += `<span>${date}</span>`;
|
|
89
|
+
html += `<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(v.version).slice(0, 12)}</span>`;
|
|
90
|
+
html += `<span class="sp-size-col" style="text-align:right">${size}</span>`;
|
|
91
|
+
html += `<span class="sp-delta-col" style="min-width:38px;text-align:right">${delta}</span>`;
|
|
92
|
+
html += '</div>';
|
|
93
|
+
}
|
|
94
|
+
container.innerHTML = html;
|
|
95
|
+
// Scroll active item into view
|
|
96
|
+
const activeEl = container.querySelector('.sp-version-item.active');
|
|
97
|
+
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isMobileLayout() {
|
|
101
|
+
return window.innerWidth < 768;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function selectVersion(idx) {
|
|
105
|
+
if (idx < 0 || idx >= spVersions.length) return;
|
|
106
|
+
spSelectedIdx = idx;
|
|
107
|
+
renderVersionList();
|
|
108
|
+
loadSelectedVersion();
|
|
109
|
+
if (isMobileLayout()) {
|
|
110
|
+
const body = document.querySelector('.sp-changelog-body');
|
|
111
|
+
if (body) body.classList.add('sp-mobile-detail');
|
|
112
|
+
updateBackButton(true);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function backToVersionList() {
|
|
117
|
+
const body = document.querySelector('.sp-changelog-body');
|
|
118
|
+
if (body) body.classList.remove('sp-mobile-detail');
|
|
119
|
+
updateBackButton(false);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function spHandleBack() {
|
|
123
|
+
if (isMobileLayout()) {
|
|
124
|
+
const body = document.querySelector('.sp-changelog-body');
|
|
125
|
+
if (body && body.classList.contains('sp-mobile-detail')) {
|
|
126
|
+
backToVersionList();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
closeDiffPanel();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function updateBackButton(showVersions) {
|
|
134
|
+
const header = document.querySelector('#diff-overlay .fp-header .fp-back');
|
|
135
|
+
if (!header) return;
|
|
136
|
+
header.textContent = showVersions ? '← Versions' : '←';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Content / Diff loading ──────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async function loadSelectedVersion() {
|
|
142
|
+
const v = spVersions[spSelectedIdx];
|
|
143
|
+
if (!v) return;
|
|
144
|
+
if (spMode === 'content') {
|
|
145
|
+
await loadContentForVersion(v);
|
|
146
|
+
} else {
|
|
147
|
+
await loadDiffForVersion(v);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function loadContentForVersion(v) {
|
|
152
|
+
const panel = document.getElementById('diff-text-panel');
|
|
153
|
+
const summary = document.getElementById('diff-summary');
|
|
154
|
+
if (summary) summary.textContent = v.version;
|
|
155
|
+
updateModeIndicator();
|
|
156
|
+
if (panel) panel.innerHTML = '<div style="color:var(--dim);font-size:11px">Loading...</div>';
|
|
157
|
+
try {
|
|
158
|
+
const data = await fetch(`/_api/sysprompt/diff?a=${encodeURIComponent(v.version)}&b=${encodeURIComponent(v.version)}&agent=claude-code`).then(r => r.json());
|
|
159
|
+
const block = (data.blockDiff || []).find(b => b.block === 'coreInstructions');
|
|
160
|
+
if (block && block.textB) {
|
|
161
|
+
panel.innerHTML = `<pre style="margin:0;font-size:11px;font-family:monospace;line-height:1.5;white-space:pre-wrap;word-break:break-word">${escapeHtml(block.textB)}</pre>`;
|
|
162
|
+
} else {
|
|
163
|
+
panel.innerHTML = '<div style="color:var(--dim);font-size:11px">No content available</div>';
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (panel) panel.innerHTML = `<div style="color:var(--red)">Error: ${escapeHtml(e.message)}</div>`;
|
|
167
|
+
}
|
|
168
|
+
updateStatusBar();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function loadDiffForVersion(v) {
|
|
172
|
+
const panel = document.getElementById('diff-text-panel');
|
|
173
|
+
const summary = document.getElementById('diff-summary');
|
|
174
|
+
const prevIdx = spSelectedIdx + 1;
|
|
175
|
+
updateModeIndicator();
|
|
176
|
+
if (prevIdx >= spVersions.length) {
|
|
177
|
+
if (summary) summary.textContent = v.version;
|
|
178
|
+
if (panel) panel.innerHTML = '<div style="color:var(--dim);font-size:11px">No previous version to compare</div>';
|
|
179
|
+
updateStatusBar();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const prev = spVersions[prevIdx];
|
|
183
|
+
if (summary) summary.textContent = `${prev.version} → ${v.version}`;
|
|
184
|
+
if (panel) panel.innerHTML = '<div style="color:var(--dim);font-size:11px">Loading...</div>';
|
|
185
|
+
try {
|
|
186
|
+
const data = await fetch(`/_api/sysprompt/diff?a=${encodeURIComponent(prev.version)}&b=${encodeURIComponent(v.version)}&agent=claude-code`).then(r => r.json());
|
|
187
|
+
const block = (data.blockDiff || []).find(b => b.block === 'coreInstructions');
|
|
188
|
+
if (!block) {
|
|
189
|
+
panel.innerHTML = '<div style="color:var(--dim);font-size:11px">coreInstructions block not found.</div>';
|
|
190
|
+
} else if (block.status === 'same') {
|
|
191
|
+
panel.innerHTML = '<div style="color:var(--dim);font-size:11px">coreInstructions: unchanged</div>';
|
|
192
|
+
} else {
|
|
193
|
+
currentHunkIdx = 0;
|
|
194
|
+
const hunks = parseHunks(block.blockDiff || '');
|
|
195
|
+
panel.innerHTML = `
|
|
196
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:10px;color:var(--dim)">
|
|
197
|
+
<button onclick="prevHunk()" style="background:none;border:1px solid var(--border);color:var(--dim);padding:1px 5px;cursor:pointer">▲ k</button>
|
|
198
|
+
<button onclick="nextHunk()" style="background:none;border:1px solid var(--border);color:var(--dim);padding:1px 5px;cursor:pointer">▼ j</button>
|
|
199
|
+
<span id="hunk-counter">${hunks.length} hunks</span>
|
|
200
|
+
<button onclick="toggleMinorEdit()" id="minor-edit-btn" style="background:none;border:1px solid var(--border);color:var(--dim);padding:1px 5px;cursor:pointer">${hideMinorEdit ? 'show MINOR EDIT' : 'hide MINOR EDIT'}</button>
|
|
201
|
+
</div>
|
|
202
|
+
<div id="diff-content">${renderHunks(hunks)}</div>`;
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
if (panel) panel.innerHTML = `<div style="color:var(--red)">Error loading diff: ${escapeHtml(e.message)}</div>`;
|
|
206
|
+
}
|
|
207
|
+
updateStatusBar();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Mode indicator & Status bar ──────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function updateModeIndicator() {
|
|
213
|
+
const badge = document.getElementById('sp-mode-badge');
|
|
214
|
+
if (!badge) return;
|
|
215
|
+
const isContent = spMode === 'content';
|
|
216
|
+
badge.textContent = isContent ? 'CONTENT' : 'DIFF';
|
|
217
|
+
badge.style.background = isContent ? 'var(--accent)' : 'var(--yellow)';
|
|
218
|
+
badge.style.color = '#000';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function updateStatusBar() {
|
|
222
|
+
const bar = document.getElementById('sp-status-bar');
|
|
223
|
+
if (!bar) return;
|
|
224
|
+
if (spMode === 'content') {
|
|
225
|
+
bar.textContent = '↑↓ navigate Space: switch to DIFF';
|
|
226
|
+
} else {
|
|
227
|
+
const hunks = document.querySelectorAll('.diff-hunk');
|
|
228
|
+
const total = hunks.length;
|
|
229
|
+
const hunkInfo = total > 0 ? ` j/k: hunk ${currentHunkIdx + 1}/${total}` : '';
|
|
230
|
+
bar.textContent = `↑↓ navigate Space: switch to CONTENT${hunkInfo}`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Mode toggle ─────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
function toggleMode() {
|
|
237
|
+
spMode = spMode === 'content' ? 'diff' : 'content';
|
|
238
|
+
loadSelectedVersion();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Diff rendering helpers ──────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
function parseHunks(unifiedDiff) {
|
|
244
|
+
const lines = unifiedDiff.split('\n');
|
|
245
|
+
const hunks = [];
|
|
246
|
+
let current = null;
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
if (line.startsWith('@@ ')) {
|
|
249
|
+
if (current) hunks.push(current);
|
|
250
|
+
current = { header: line, lines: [] };
|
|
251
|
+
} else if (current) {
|
|
252
|
+
current.lines.push(line);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (current) hunks.push(current);
|
|
256
|
+
return hunks;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function classifyHunk(hunk) {
|
|
260
|
+
const adds = hunk.lines.filter(l => l.startsWith('+')).length;
|
|
261
|
+
const dels = hunk.lines.filter(l => l.startsWith('-')).length;
|
|
262
|
+
if (adds >= 5 && dels === 0) return 'NEW SECTION';
|
|
263
|
+
if (adds > 0 && dels > 0 && adds > dels) return 'EXPANSION';
|
|
264
|
+
if (adds > 0 && dels > 0 && dels >= adds) return 'REVISION';
|
|
265
|
+
if (adds + dels <= 2) return 'MINOR EDIT';
|
|
266
|
+
return 'EXPANSION';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderHunks(hunks) {
|
|
270
|
+
let html = '';
|
|
271
|
+
for (const h of hunks) {
|
|
272
|
+
const cls = classifyHunk(h);
|
|
273
|
+
const clsColor = cls === 'NEW SECTION' ? 'var(--green)' : cls === 'MINOR EDIT' ? 'var(--dim)' : 'var(--yellow)';
|
|
274
|
+
if (hideMinorEdit && cls === 'MINOR EDIT') continue;
|
|
275
|
+
html += `<div class="diff-hunk" data-cls="${cls}">`;
|
|
276
|
+
html += `<div style="color:var(--dim);font-size:10px;margin:6px 0 2px">${escapeHtml(h.header)} <span style="color:${clsColor}">[${cls}]</span></div>`;
|
|
277
|
+
html += '<pre style="margin:0;font-size:11px;font-family:monospace;line-height:1.5">';
|
|
278
|
+
for (const line of h.lines) {
|
|
279
|
+
const bg = line.startsWith('+') ? 'rgba(46,160,67,0.15)' : line.startsWith('-') ? 'rgba(248,81,73,0.15)' : 'transparent';
|
|
280
|
+
const color = line.startsWith('+') ? 'var(--color-diff-add)' : line.startsWith('-') ? 'var(--color-diff-del)' : 'var(--dim)';
|
|
281
|
+
html += `<span style="display:block;background:${bg};color:${color}">${escapeHtml(line)}</span>`;
|
|
282
|
+
}
|
|
283
|
+
html += '</pre></div>';
|
|
284
|
+
}
|
|
285
|
+
return html || '<div style="color:var(--dim);font-size:11px">No diff content</div>';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function toggleMinorEdit() {
|
|
289
|
+
hideMinorEdit = !hideMinorEdit;
|
|
290
|
+
const v = spVersions[spSelectedIdx];
|
|
291
|
+
if (v && spMode === 'diff') loadDiffForVersion(v);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function nextHunk() {
|
|
295
|
+
const hunks = document.querySelectorAll('.diff-hunk');
|
|
296
|
+
if (currentHunkIdx < hunks.length - 1) currentHunkIdx++;
|
|
297
|
+
hunks[currentHunkIdx]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
298
|
+
updateStatusBar();
|
|
299
|
+
}
|
|
300
|
+
function prevHunk() {
|
|
301
|
+
const hunks = document.querySelectorAll('.diff-hunk');
|
|
302
|
+
if (currentHunkIdx > 0) currentHunkIdx--;
|
|
303
|
+
hunks[currentHunkIdx]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
304
|
+
updateStatusBar();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Keyboard handler ────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
document.addEventListener('keydown', (e) => {
|
|
310
|
+
const overlay = document.getElementById('diff-overlay');
|
|
311
|
+
if (!overlay || !overlay.classList.contains('open')) return;
|
|
312
|
+
|
|
313
|
+
if (e.key === 'ArrowDown') {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
if (spSelectedIdx < spVersions.length - 1) selectVersion(spSelectedIdx + 1);
|
|
316
|
+
} else if (e.key === 'ArrowUp') {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
if (spSelectedIdx > 0) selectVersion(spSelectedIdx - 1);
|
|
319
|
+
} else if (e.key === ' ') {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
toggleMode();
|
|
322
|
+
} else if (e.key === 'j') {
|
|
323
|
+
nextHunk();
|
|
324
|
+
} else if (e.key === 'k') {
|
|
325
|
+
prevHunk();
|
|
326
|
+
}
|
|
327
|
+
});
|
package/server/auth.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Simple API key authentication middleware for cloud deployments.
|
|
5
|
+
*
|
|
6
|
+
* Enable by setting AUTH_TOKEN environment variable.
|
|
7
|
+
* When set, all requests must include either:
|
|
8
|
+
* - Header: Authorization: Bearer <token>
|
|
9
|
+
* - Query param: ?token=<token>
|
|
10
|
+
*
|
|
11
|
+
* Dashboard and SSE endpoints are also protected.
|
|
12
|
+
* The proxy endpoint (forwarding to Anthropic) uses the client's own API key
|
|
13
|
+
* for Anthropic auth, but still requires AUTH_TOKEN for access control.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const AUTH_TOKEN = process.env.AUTH_TOKEN || null;
|
|
17
|
+
|
|
18
|
+
function authMiddleware(req, res) {
|
|
19
|
+
if (!AUTH_TOKEN) return true; // no auth configured — allow all
|
|
20
|
+
|
|
21
|
+
// Check Authorization header
|
|
22
|
+
const authHeader = req.headers['authorization'] || '';
|
|
23
|
+
if (authHeader === `Bearer ${AUTH_TOKEN}`) return true;
|
|
24
|
+
|
|
25
|
+
// Check query param
|
|
26
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
27
|
+
if (url.searchParams.get('token') === AUTH_TOKEN) return true;
|
|
28
|
+
|
|
29
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
30
|
+
res.end(JSON.stringify({ error: 'unauthorized', message: 'Valid AUTH_TOKEN required' }));
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { authMiddleware, AUTH_TOKEN };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
|
|
8
|
+
// Resolve AWS credentials for Bedrock. Returns { accessKeyId, secretAccessKey, sessionToken } or null.
|
|
9
|
+
// Resolution order:
|
|
10
|
+
// 1. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY env vars
|
|
11
|
+
// 2. ~/.aws/credentials profile (AWS_PROFILE or 'default')
|
|
12
|
+
// 3. EC2/ECS IMDSv2 (1s timeout)
|
|
13
|
+
async function resolveCredentials() {
|
|
14
|
+
// 1. Env vars
|
|
15
|
+
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
|
16
|
+
return {
|
|
17
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
18
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
19
|
+
sessionToken: process.env.AWS_SESSION_TOKEN || null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. ~/.aws/credentials file
|
|
24
|
+
const profile = process.env.AWS_PROFILE || 'default';
|
|
25
|
+
const credFile = process.env.AWS_SHARED_CREDENTIALS_FILE
|
|
26
|
+
|| path.join(os.homedir(), '.aws', 'credentials');
|
|
27
|
+
try {
|
|
28
|
+
const text = fs.readFileSync(credFile, 'utf8');
|
|
29
|
+
const creds = parseCredentialsFile(text, profile);
|
|
30
|
+
if (creds) return creds;
|
|
31
|
+
} catch {}
|
|
32
|
+
|
|
33
|
+
// 3. IMDSv2 (EC2/ECS instance metadata)
|
|
34
|
+
try {
|
|
35
|
+
const creds = await fetchImdsCredentials();
|
|
36
|
+
if (creds) return creds;
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseCredentialsFile(text, profile) {
|
|
43
|
+
const lines = text.split('\n');
|
|
44
|
+
let inProfile = false;
|
|
45
|
+
const result = {};
|
|
46
|
+
for (const raw of lines) {
|
|
47
|
+
const line = raw.trim();
|
|
48
|
+
if (line.startsWith('[')) {
|
|
49
|
+
inProfile = line === `[${profile}]`;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!inProfile || !line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
53
|
+
const eqIdx = line.indexOf('=');
|
|
54
|
+
if (eqIdx === -1) continue;
|
|
55
|
+
const key = line.slice(0, eqIdx).trim();
|
|
56
|
+
const val = line.slice(eqIdx + 1).trim();
|
|
57
|
+
if (key === 'aws_access_key_id') result.accessKeyId = val;
|
|
58
|
+
if (key === 'aws_secret_access_key') result.secretAccessKey = val;
|
|
59
|
+
if (key === 'aws_session_token') result.sessionToken = val;
|
|
60
|
+
}
|
|
61
|
+
return (result.accessKeyId && result.secretAccessKey) ? result : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fetchImdsCredentials() {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const TIMEOUT = 1000;
|
|
67
|
+
const IMDS_HOST = process.env.CCXRAY_IMDS_HOST || '169.254.169.254';
|
|
68
|
+
|
|
69
|
+
function fail() { resolve(null); }
|
|
70
|
+
|
|
71
|
+
// Step 1: Get IMDSv2 token
|
|
72
|
+
const tokenReq = http.request({
|
|
73
|
+
hostname: IMDS_HOST,
|
|
74
|
+
path: '/latest/api/token',
|
|
75
|
+
method: 'PUT',
|
|
76
|
+
headers: { 'X-aws-ec2-metadata-token-ttl-seconds': '21600' },
|
|
77
|
+
}, (res) => {
|
|
78
|
+
let token = '';
|
|
79
|
+
res.on('data', c => { token += c; });
|
|
80
|
+
res.on('end', () => {
|
|
81
|
+
try {
|
|
82
|
+
// Strip non-printable / non-ASCII chars — non-EC2 IMDS endpoints
|
|
83
|
+
// (e.g. Azure link-local) may return HTML that causes ERR_INVALID_CHAR.
|
|
84
|
+
token = token.trim().replace(/[^\x20-\x7E]/g, '');
|
|
85
|
+
if (!token) return fail();
|
|
86
|
+
|
|
87
|
+
// Step 2: Get IAM role name
|
|
88
|
+
const roleReq = http.request({
|
|
89
|
+
hostname: IMDS_HOST,
|
|
90
|
+
path: '/latest/meta-data/iam/security-credentials/',
|
|
91
|
+
method: 'GET',
|
|
92
|
+
headers: { 'X-aws-ec2-metadata-token': token },
|
|
93
|
+
}, (res2) => {
|
|
94
|
+
let roleName = '';
|
|
95
|
+
res2.on('data', c => { roleName += c; });
|
|
96
|
+
res2.on('end', () => {
|
|
97
|
+
roleName = roleName.trim().split('\n')[0].trim();
|
|
98
|
+
if (!roleName) return fail();
|
|
99
|
+
|
|
100
|
+
// Step 3: Get credentials for the role
|
|
101
|
+
const credsReq = http.request({
|
|
102
|
+
hostname: IMDS_HOST,
|
|
103
|
+
path: `/latest/meta-data/iam/security-credentials/${roleName}`,
|
|
104
|
+
method: 'GET',
|
|
105
|
+
headers: { 'X-aws-ec2-metadata-token': token },
|
|
106
|
+
}, (res3) => {
|
|
107
|
+
let body = '';
|
|
108
|
+
res3.on('data', c => { body += c; });
|
|
109
|
+
res3.on('end', () => {
|
|
110
|
+
try {
|
|
111
|
+
const data = JSON.parse(body);
|
|
112
|
+
if (data.AccessKeyId && data.SecretAccessKey) {
|
|
113
|
+
resolve({
|
|
114
|
+
accessKeyId: data.AccessKeyId,
|
|
115
|
+
secretAccessKey: data.SecretAccessKey,
|
|
116
|
+
sessionToken: data.Token || null,
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
fail();
|
|
120
|
+
}
|
|
121
|
+
} catch { fail(); }
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
credsReq.setTimeout(TIMEOUT, () => { credsReq.destroy(); fail(); });
|
|
125
|
+
credsReq.on('error', fail);
|
|
126
|
+
credsReq.end();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
roleReq.setTimeout(TIMEOUT, () => { roleReq.destroy(); fail(); });
|
|
130
|
+
roleReq.on('error', fail);
|
|
131
|
+
roleReq.end();
|
|
132
|
+
} catch { fail(); }
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
tokenReq.setTimeout(TIMEOUT, () => { tokenReq.destroy(); fail(); });
|
|
136
|
+
tokenReq.on('error', fail);
|
|
137
|
+
tokenReq.end();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { resolveCredentials, parseCredentialsFile };
|