Skip to content

Commit 591ea2d

Browse files
committed
Reject duplicate CommandCode API keys
1 parent 07d1bc6 commit 591ea2d

4 files changed

Lines changed: 81 additions & 2 deletions

File tree

src/dashboard.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const expiredIds=new Set();
5252
function fullBridgeKey(){const raw=($('bridgeApiKey')?.value||'').trim(); if(!raw)return ''; return raw.startsWith('sk-')?raw:'sk-'+raw;}
5353
function displayBridgeKey(key){return key?.startsWith('sk-')?key.slice(3):(key||'')}
5454
function auth(){const key=localStorage.getItem('bridgeApiKey')||''; return key?{'authorization':'Bearer '+key,'content-type':'application/json'}:{'content-type':'application/json'}}
55+
function duplicateCredentialMessage(e){const text=String(e?.message||e||''); return text.includes('duplicate_commandcode_api_key')||text.includes('Duplicate CommandCode API key')?'이미 등록된 키입니다. 다른 CommandCode API key를 입력해주세요.':null;}
56+
function preventDuplicateVisibleKey(input){const value=input.value.trim(); if(!value)return false; const dup=Array.from(document.querySelectorAll('[data-ckey]')).some(el=>el!==input&&el.value.trim()===value); if(!dup)return false; const i=+input.dataset.ckey; input.value=''; if(cfg?.credentials?.[i])cfg.credentials[i].apiKey=''; toast('이미 등록된 키입니다. 입력하지 않았습니다.'); return true;}
5557
function syncBridgeKey(){const el=$('bridgeApiKey'); if(!el)return; const configured=cfg?.bridgeApiKey||''; const stored=localStorage.getItem('bridgeApiKey')||''; const key=configured||stored; if(configured&&stored!==configured)localStorage.setItem('bridgeApiKey',configured); if(document.activeElement!==el) el.value=displayBridgeKey(key); const wrap=$('bridgeKeyWrap'); if(wrap) wrap.style.display=$('bindHost')?.value==='0.0.0.0'?'grid':'none';}
5658
function setDirty(v=true){dirty=v; $('dirtyText').textContent=v?'pending changes · restart required':'no pending changes'; updateRestart();}
5759
function updateRestart(){const online=$('online').textContent==='online'; $('restart').disabled=online&&!dirty; $('restart').classList.toggle('active',!$('restart').disabled)}
@@ -63,10 +65,10 @@ async function load(){if(cfg){if(cfg.bridge) setOnline(cfg.bridge); render(); se
6365
function render(){if(!cfg)return; $('bindHost').value=cfg.server.host; $('bindPort').value=cfg.server.port; $('maxPer').value=cfg.routing.maxInFlightPerCredential||4; syncBridgeKey();
6466
$('policies').innerHTML=policies.map(p=>'<label class="policy"><input type="radio" name="policy" value="'+esc(p[0])+'" '+(cfg.routing.policy===p[0]?'checked':'')+'><b>'+esc(p[1])+'</b><span class="info" tabindex="0" aria-label="'+esc(p[1])+' 설명">ℹ️<span class="tip">'+esc(p[2])+'<br><span class="token">'+esc(p[0])+'</span></span></span></label>').join(''); document.querySelectorAll('input[name=policy]').forEach(el=>el.onchange=()=>{cfg.routing.policy=el.value;setDirty();});
6567
$('creds').innerHTML=cfg.credentials.map((c,i)=>{c.originalId=c.originalId||c.id; if(c.enabled===undefined)c.enabled=true; const m=c.metrics||{}; const b=m.billing||{}; const bm=b.metrics||{}; const bal=Number(bm.currentBalance??b.currentBalance??b.monthlyCredits); const days=Number(bm.daysRemaining??b.daysRemaining); const daily=Number(bm.requiredDailyBurn??b.requiredDailyBurn); const money=Number.isFinite(bal)?'$'+bal.toFixed(2):'unknown'; const expired=Number.isFinite(days)&&days<=0; if(expired)expiredIds.add(c.id); else expiredIds.delete(c.id); const dayText=Number.isFinite(days)?Math.max(0,days).toFixed(1)+' 일':'unknown'; const dailyText=Number.isFinite(daily)?'$'+daily.toFixed(2)+'/일':'unknown'; const status=expired?'expired':(c.enabled===false?'manual off':(m.disabledUntil?'disabled':(m.available===false?'blocked':'ready'))); return '<div class="cred"><div class="cred-head"><input class="cred-name" data-cid="'+i+'" value="'+esc(c.id||('key'+(i+1)))+'"><label class="switch" title="사용 여부"><input data-cenabled="'+i+'" type="checkbox" '+(c.enabled!==false&&!expired?'checked':'')+' '+(expired?'disabled':'')+'><span class="slider"></span></label><button class="danger" data-del="'+i+'">Delete</button></div><div class="kv"><span>Status</span><b>'+esc(status)+'</b></div><div class="kv"><span>잔액</span><b>'+esc(money)+'</b></div><div class="kv"><span>기한</span><b>'+esc(dayText)+'</b></div><div class="kv"><span>잔액/기한</span><b>'+esc(dailyText)+'</b></div><div class="field"><label>API Key '+(c.apiKeyConfigured?'('+esc(c.apiKeyPreview)+')':'')+'</label><input data-ckey="'+i+'" type="password" placeholder="leave blank to keep existing"></div></div>'}).join('');
66-
document.querySelectorAll('[data-cid]').forEach(e=>e.oninput=()=>{cfg.credentials[+e.dataset.cid].id=e.value;setDirty();}); document.querySelectorAll('[data-cenabled]').forEach(e=>e.onchange=()=>{cfg.credentials[+e.dataset.cenabled].enabled=e.checked;setDirty();}); document.querySelectorAll('[data-ckey]').forEach(e=>e.oninput=()=>{cfg.credentials[+e.dataset.ckey].apiKey=e.value;setDirty();}); document.querySelectorAll('[data-del]').forEach(e=>e.onclick=()=>{cfg.credentials.splice(+e.dataset.del,1);render();setDirty();});
68+
document.querySelectorAll('[data-cid]').forEach(e=>e.oninput=()=>{cfg.credentials[+e.dataset.cid].id=e.value;setDirty();}); document.querySelectorAll('[data-cenabled]').forEach(e=>e.onchange=()=>{cfg.credentials[+e.dataset.cenabled].enabled=e.checked;setDirty();}); document.querySelectorAll('[data-ckey]').forEach(e=>e.oninput=()=>{if(preventDuplicateVisibleKey(e))return; cfg.credentials[+e.dataset.ckey].apiKey=e.value;setDirty();}); document.querySelectorAll('[data-del]').forEach(e=>e.onclick=()=>{cfg.credentials.splice(+e.dataset.del,1);render();setDirty();});
6769
$('models').innerHTML=cfg.models.map((m,i)=>'<div class="model"><div class="model-head"><div><b>'+esc(m.label||m.id)+'</b><div class="small">'+esc(m.provider)+' · '+esc(m.id)+'</div></div><label class="switch"><input data-mid="'+i+'" type="checkbox" '+(m.enabled?'checked':'')+'><span class="slider"></span></label></div><div class="small">'+esc(m.notes||'')+'</div></div>').join(''); document.querySelectorAll('[data-mid]').forEach(e=>e.onchange=()=>{cfg.models[+e.dataset.mid].enabled=e.checked;setDirty();});}
6870
$('bindHost').onchange=()=>{cfg.server.host=$('bindHost').value;syncBridgeKey();setDirty();}; $('bindPort').oninput=()=>{cfg.server.port=Number($('bindPort').value)||9992;setDirty();}; function saveBridgeKey(){const key=fullBridgeKey(); if(key)localStorage.setItem('bridgeApiKey',key); else localStorage.removeItem('bridgeApiKey'); syncBridgeKey(); toast(key?'Admin API key saved in this browser.':'Admin API key cleared.');} async function copyBridgeKey(){const key=fullBridgeKey()||localStorage.getItem('bridgeApiKey')||''; if(!key){toast('Admin API key is empty.');return;} try{await navigator.clipboard.writeText(key);toast('Admin API key copied.');}catch{toast('Copy failed. Select and copy manually.');}} $('saveBridgeKey').onclick=saveBridgeKey; $('copyBridgeKey').onclick=copyBridgeKey; $('bridgeApiKey').onkeydown=e=>{if(e.key==='Enter')saveBridgeKey();}; $('maxPer').oninput=()=>{cfg.routing.maxInFlightPerCredential=Number($('maxPer').value)||4; setDirty();}; $('refreshCreds').onclick=async()=>{try{const m=await api('/admin/commandcode/credentials?refresh=true'); const byId=new Map((m.credentials||[]).map(x=>[x.id,x])); cfg.credentials.forEach(c=>c.metrics=byId.get(c.id)); render(); toast('Credentials refreshed.');}catch(e){toast('Credential refresh failed.');}}; $('addCred').onclick=()=>{cfg.credentials.push({id:'key'+(cfg.credentials.length+1),apiKey:'',weight:1,enabled:true});render();setDirty();};
69-
$('save').onclick=async()=>{try{if(!cfg)throw new Error('config not loaded'); const payload={server:cfg.server,routing:cfg.routing,models:cfg.models,credentials:cfg.credentials.map(c=>({id:c.id,originalId:c.originalId,apiKey:c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels}))}; cfg=await api('/admin/config',{method:'PUT',body:JSON.stringify(payload)}); render(); setDirty(true); toast('JSON saved. Restart required.');}catch(e){toast('Save failed: '+(e?.message||e));}};
71+
$('save').onclick=async()=>{try{if(!cfg)throw new Error('config not loaded'); const payload={server:cfg.server,routing:cfg.routing,models:cfg.models,credentials:cfg.credentials.map(c=>({id:c.id,originalId:c.originalId,apiKey:c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels}))}; cfg=await api('/admin/config',{method:'PUT',body:JSON.stringify(payload)}); render(); setDirty(true); toast('JSON saved. Restart required.');}catch(e){toast(duplicateCredentialMessage(e)||('Save failed: '+(e?.message||e)));}};
7072
$('restart').onclick=async()=>{try{await api('/admin/restart',{method:'POST',body:'{}'}); toast('Restart requested'); setTimeout(load,1800);}catch(e){toast('Restart failed: '+(e?.message||e));}};
7173
load();
7274
</script>

src/server.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,24 @@ function withExistingCredentialSecrets(
183183
return merged;
184184
}
185185

186+
function duplicateCommandCodeApiKeyIds(update: DashboardConfigUpdate): string[] {
187+
const seen = new Map<string, string>();
188+
const duplicateIds = new Set<string>();
189+
for (const credential of update.credentials ?? []) {
190+
const apiKey = typeof credential.apiKey === "string" ? credential.apiKey.trim() : "";
191+
if (!apiKey) continue;
192+
const id = typeof credential.id === "string" && credential.id.trim() ? credential.id.trim() : "unknown";
193+
const existingId = seen.get(apiKey);
194+
if (existingId) {
195+
duplicateIds.add(existingId);
196+
duplicateIds.add(id);
197+
continue;
198+
}
199+
seen.set(apiKey, id);
200+
}
201+
return Array.from(duplicateIds);
202+
}
203+
186204
function dashboardConfigResponse(
187205
config: BridgeConfig,
188206
dirty: boolean,
@@ -408,6 +426,17 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Fastify
408426
request.body as DashboardConfigUpdate,
409427
secretSourceConfig,
410428
);
429+
const duplicateIds = duplicateCommandCodeApiKeyIds(update);
430+
if (duplicateIds.length > 0) {
431+
return reply.code(409).send({
432+
...openAIError(
433+
`Duplicate CommandCode API key for credential ids: ${duplicateIds.join(", ")}`,
434+
"invalid_request_error",
435+
"duplicate_commandcode_api_key",
436+
),
437+
duplicateCredentialIds: duplicateIds,
438+
});
439+
}
411440
writeDashboardConfigFile(config.configFilePath, update);
412441
configDirty = true;
413442
const savedConfig = loadBridgeConfig({

tests/admin-config.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,41 @@ describe("JSON dashboard configuration", () => {
179179
await app.close();
180180
});
181181

182+
it("rejects dashboard saves that would duplicate an existing API key", async () => {
183+
const file = tempConfigFile({
184+
routing: { policy: "daily_burn_priority", maxInFlightPerCredential: 4 },
185+
credentials: [{ id: "alpha", apiKey: "alpha-secret", weight: 1 }],
186+
});
187+
const app = await createApp({
188+
upstream: new FakeCommandCodeClient(),
189+
configEnv: { COMMANDCODE_CREDENTIALS_FILE: file },
190+
configAuthPaths: [],
191+
configOverrides: { bridgeApiKey: "bridge-secret", logLevel: "silent" },
192+
});
193+
194+
const response = await app.inject({
195+
method: "PUT",
196+
url: "/admin/config",
197+
headers: { authorization: "Bearer bridge-secret" },
198+
payload: {
199+
routing: { policy: "daily_burn_priority", maxInFlightPerCredential: 4 },
200+
credentials: [
201+
{ id: "alpha", originalId: "alpha", weight: 1 },
202+
{ id: "beta", apiKey: "alpha-secret", weight: 1 },
203+
],
204+
},
205+
});
206+
207+
expect(response.statusCode).toBe(409);
208+
expect(response.body).toContain("duplicate_commandcode_api_key");
209+
expect(response.body).not.toContain("alpha-secret");
210+
expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({
211+
credentials: [{ id: "alpha", apiKey: "alpha-secret" }],
212+
});
213+
214+
await app.close();
215+
});
216+
182217
it("serves the mobile dashboard shell without admin authorization", async () => {
183218
const app = await createApp({
184219
upstream: new FakeCommandCodeClient(),

tests/dashboard-ui.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,17 @@ describe("dashboard UI", () => {
9393
expect(html).toContain(".footerbar .token{grid-column:1/-1");
9494
expect(html).toContain(".footerbar button{width:100%;min-width:0}");
9595
});
96+
97+
it("includes duplicate API key validation in the dashboard save flow", () => {
98+
const html = dashboardHtml({
99+
server: { host: "127.0.0.1", port: 9992 },
100+
routing: { policy: "daily_burn_priority", maxInFlightPerCredential: 4 },
101+
credentials: [{ id: "alpha", apiKeyConfigured: true, apiKeyPreview: "alph…cret" }],
102+
models: [],
103+
});
104+
105+
expect(html).toContain("duplicateCredentialMessage");
106+
expect(html).toContain("이미 등록된 키입니다");
107+
expect(html).toContain("Duplicate CommandCode API key");
108+
});
96109
});

0 commit comments

Comments
 (0)