Skip to content

Commit c1c7f08

Browse files
committed
Persist dashboard admin key updates
1 parent f2e36df commit c1c7f08

6 files changed

Lines changed: 34 additions & 5 deletions

File tree

src/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ function parseCsv(value: string | undefined): string[] {
8181
.filter(Boolean);
8282
}
8383

84+
function stringValue(value: unknown): string | undefined {
85+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
86+
}
87+
8488
function uniq(values: string[]): string[] {
8589
return Array.from(new Set(values));
8690
}
@@ -174,7 +178,7 @@ export function loadBridgeConfig(options: LoadBridgeConfigOptions = {}): BridgeC
174178
defaultModel,
175179
allowedModels,
176180
allowUnknownModels: parseBoolean(env.COMMANDCODE_ALLOW_UNKNOWN_MODELS, false),
177-
bridgeApiKey: env.BRIDGE_API_KEY?.trim() || undefined,
181+
bridgeApiKey: stringValue(dashboardConfig.bridgeApiKey) || env.BRIDGE_API_KEY?.trim() || undefined,
178182
commandCodeApiKey: commandCodeCredentials[0]?.apiKey,
179183
commandCodeCredentials,
180184
commandCodeRoutingPolicy: routingPolicy,

src/dashboard-config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface DashboardServerConfig {
1616
}
1717

1818
export interface CommandCodeDashboardConfigFile {
19+
bridgeApiKey?: string;
1920
server?: Partial<DashboardServerConfig>;
2021
routing?: Partial<CommandCodeRoutingConfig>;
2122
models?: Array<Partial<CommandCodeModelConfig>>;
@@ -45,6 +46,7 @@ export interface DashboardConfigView {
4546
}
4647

4748
export interface DashboardConfigUpdate {
49+
bridgeApiKey?: string;
4850
server?: Partial<DashboardServerConfig>;
4951
routing?: Partial<CommandCodeRoutingConfig>;
5052
models?: Array<Partial<CommandCodeModelConfig>>;
@@ -261,7 +263,9 @@ export function redactedCredentials(
261263
export function buildWritableDashboardConfig(
262264
update: DashboardConfigUpdate,
263265
): CommandCodeDashboardConfigFile {
266+
const bridgeApiKey = stringValue(update.bridgeApiKey);
264267
return {
268+
...(bridgeApiKey ? { bridgeApiKey } : {}),
265269
server: normalizeServerConfig(update.server),
266270
routing: normalizeRoutingConfig(update.routing),
267271
models: normalizeModelUpdate(update.models ?? []),

src/dashboard.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ $('creds').innerHTML=cfg.credentials.map((c,i)=>{c.originalId=c.originalId||c.id
7070
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();});
7171
$('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();});}
7272
function randomBridgeKey(){const bytes=new Uint8Array(3); if(globalThis.crypto?.getRandomValues) crypto.getRandomValues(bytes); else for(let i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256); return 'sk-cmdbridge-'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
73-
function generateBridgeKey(){const key=randomBridgeKey(); const el=$('bridgeApiKey'); if(el)el.value=displayBridgeKey(key); saveBridgeKey(key); setDirty(); toast('Random Admin API key generated. Save JSON and restart.');}
73+
function generateBridgeKey(){const key=randomBridgeKey(); const el=$('bridgeApiKey'); if(el)el.value=displayBridgeKey(key); setDirty(); toast('Random Admin API key generated. Save JSON, then restart to apply.');}
7474
$('bindHost').onchange=()=>{cfg.server.host=$('bindHost').value;syncBridgeKey();setDirty();}; $('bindPort').oninput=()=>{cfg.server.port=Number($('bindPort').value)||9992;setDirty();}; function saveBridgeKey(nextKey){const key=nextKey||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()||authKey(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.');}} $('generateBridgeKey').onclick=generateBridgeKey; $('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();};
75-
$('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)));}};
76-
$('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));}};
75+
$('save').onclick=async()=>{try{if(!cfg)throw new Error('config not loaded'); const pendingBridgeKey=fullBridgeKey(); const payload={...(pendingBridgeKey?{bridgeApiKey:pendingBridgeKey}:{}),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)));}};
76+
$('restart').onclick=async()=>{const pendingBridgeKey=fullBridgeKey(); try{await api('/admin/restart',{method:'POST',body:'{}'}); if(pendingBridgeKey)localStorage.setItem('bridgeApiKey',pendingBridgeKey); toast('Restart requested'); setTimeout(load,1800);}catch(e){toast('Restart failed: '+(e?.message||e));}};
7777
load();
7878
</script>
7979
</body></html>`;

src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,9 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Fastify
426426
request.body as DashboardConfigUpdate,
427427
secretSourceConfig,
428428
);
429+
if (!update.bridgeApiKey && secretSourceConfig.bridgeApiKey) {
430+
update.bridgeApiKey = secretSourceConfig.bridgeApiKey;
431+
}
429432
const duplicateIds = duplicateCommandCodeApiKeyIds(update);
430433
if (duplicateIds.length > 0) {
431434
return reply.code(409).send({

tests/admin-config.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ describe("JSON dashboard configuration", () => {
5252
expect((statSync(file).mode & 0o777).toString(8)).toBe("600");
5353
});
5454

55+
it("persists and loads dashboard-managed admin API keys", () => {
56+
const dir = mkdtempSync(join(tmpdir(), "commandcode-bridge-admin-key-"));
57+
const file = join(dir, "credentials.json");
58+
writeDashboardConfigFile(file, {
59+
bridgeApiKey: "sk-cmdbridge-123abc",
60+
credentials: [{ id: "alpha", apiKey: "alpha-secret" }],
61+
});
62+
63+
const persisted = JSON.parse(readFileSync(file, "utf8")) as { bridgeApiKey?: string };
64+
expect(persisted.bridgeApiKey).toBe("sk-cmdbridge-123abc");
65+
66+
const config = loadBridgeConfig({
67+
env: { COMMANDCODE_CREDENTIALS_FILE: file, BRIDGE_API_KEY: "old-env-key" },
68+
authPaths: [],
69+
});
70+
expect(config.bridgeApiKey).toBe("sk-cmdbridge-123abc");
71+
});
72+
5573
it("lets JSON routing policy drive dashboard-managed configuration over legacy env", () => {
5674
const file = tempConfigFile({
5775
routing: { policy: "round_robin", maxInFlightPerCredential: 4 },

tests/dashboard-ui.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,6 @@ describe("dashboard UI", () => {
150150
expect(html).toContain("function randomBridgeKey");
151151
expect(html).toContain("cmdbridge-");
152152
expect(html).toContain("generateBridgeKey");
153-
expect(html).toContain("saveBridgeKey(key)");
153+
expect(html).toContain("bridgeApiKey:pendingBridgeKey");
154154
});
155155
});

0 commit comments

Comments
 (0)