Extract dashboard settings routes

This commit is contained in:
ejclaw
2026-04-28 18:00:35 +09:00
parent 0189852432
commit cbb8e6bc85
3 changed files with 476 additions and 179 deletions

View File

@@ -47,20 +47,7 @@ import {
sanitizeScheduledTask, sanitizeScheduledTask,
} from './web-dashboard-data.js'; } from './web-dashboard-data.js';
import { handleSimpleGetRoute } from './web-dashboard-routes.js'; import { handleSimpleGetRoute } from './web-dashboard-routes.js';
import { import { handleSettingsRoute } from './web-dashboard-settings-routes.js';
addClaudeAccountFromToken,
getActiveCodexSettingsIndex,
getFastMode,
getModelConfig,
listClaudeAccounts,
listCodexAccounts,
refreshAllCodexAccounts,
refreshCodexAccount,
removeAccountDirectory,
setActiveCodexSettingsIndex,
updateFastMode,
updateModelConfig,
} from './settings-store.js';
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
@@ -1258,155 +1245,12 @@ export function createWebDashboardHandler(
} }
} }
if ( const settingsRoute = await handleSettingsRoute({
url.pathname === '/api/settings/models' && url,
(request.method === 'PUT' || request.method === 'PATCH') request,
) { jsonResponse,
let body: unknown = null; });
try { if (settingsRoute) return settingsRoute;
body = await request.json();
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body || typeof body !== 'object') {
return jsonResponse(
{ error: 'Body must be a JSON object' },
{ status: 400 },
);
}
try {
const next = updateModelConfig(body as Record<string, unknown>);
return jsonResponse(next);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 500 });
}
}
if (
url.pathname === '/api/settings/fast-mode' &&
(request.method === 'PUT' || request.method === 'PATCH')
) {
let body: unknown = null;
try {
body = await request.json();
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body || typeof body !== 'object') {
return jsonResponse(
{ error: 'Body must be a JSON object' },
{ status: 400 },
);
}
try {
const next = updateFastMode(body as Record<string, unknown>);
return jsonResponse(next);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 500 });
}
}
{
const accountAddMatch = url.pathname.match(
/^\/api\/settings\/accounts\/(claude)$/,
);
if (accountAddMatch && request.method === 'POST') {
let body: { token?: unknown } | null = null;
try {
body = (await request.json()) as { token?: unknown };
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
const token = typeof body?.token === 'string' ? body.token.trim() : '';
if (!token) {
return jsonResponse({ error: 'token is required' }, { status: 400 });
}
try {
const result = addClaudeAccountFromToken(token);
return jsonResponse({ ok: true, ...result });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 400 });
}
}
}
{
const accountDelMatch = url.pathname.match(
/^\/api\/settings\/accounts\/(claude|codex)\/(\d+)$/,
);
if (accountDelMatch && request.method === 'DELETE') {
const provider = accountDelMatch[1] as 'claude' | 'codex';
const index = Number.parseInt(accountDelMatch[2], 10);
try {
removeAccountDirectory(provider, index);
return jsonResponse({ ok: true, provider, index });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 400 });
}
}
}
{
const refreshMatch = url.pathname.match(
/^\/api\/settings\/accounts\/codex\/(\d+)\/refresh$/,
);
if (refreshMatch && request.method === 'POST') {
const index = Number.parseInt(refreshMatch[1], 10);
try {
const updated = await refreshCodexAccount(index);
return jsonResponse({ ok: true, account: updated });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 400 });
}
}
}
if (
url.pathname === '/api/settings/accounts/codex/refresh-all' &&
request.method === 'POST'
) {
try {
const result = await refreshAllCodexAccounts();
return jsonResponse({ ok: true, ...result });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 500 });
}
}
if (
url.pathname === '/api/settings/accounts/codex/current' &&
request.method === 'PUT'
) {
let body: { index?: unknown } | null = null;
try {
body = (await request.json()) as { index?: unknown };
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
const idx = typeof body?.index === 'number' ? body.index : Number.NaN;
if (!Number.isInteger(idx)) {
return jsonResponse(
{ error: 'index must be an integer' },
{ status: 400 },
);
}
try {
setActiveCodexSettingsIndex(idx);
return jsonResponse({
ok: true,
codexCurrentIndex: getActiveCodexSettingsIndex(),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 400 });
}
}
if (url.pathname === '/api/tasks' && request.method === 'POST') { if (url.pathname === '/api/tasks' && request.method === 'POST') {
if (!loadRoomBindings) { if (!loadRoomBindings) {
@@ -1599,22 +1443,6 @@ export function createWebDashboardHandler(
}); });
} }
if (url.pathname === '/api/settings/accounts') {
return jsonResponse({
claude: listClaudeAccounts(),
codex: listCodexAccounts(),
codexCurrentIndex: getActiveCodexSettingsIndex(),
});
}
if (url.pathname === '/api/settings/models') {
return jsonResponse(getModelConfig());
}
if (url.pathname === '/api/settings/fast-mode') {
return jsonResponse(getFastMode());
}
if (url.pathname === '/api/stream') { if (url.pathname === '/api/stream') {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const stream = new ReadableStream({ const stream = new ReadableStream({

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest';
import {
handleSettingsRoute,
type SettingsRouteDependencies,
} from './web-dashboard-settings-routes.js';
import type {
ClaudeAccountSummary,
CodexAccountSummary,
FastModeSnapshot,
ModelConfigSnapshot,
} from './settings-store.js';
function jsonResponse(value: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(value), {
...init,
headers: {
'content-type': 'application/json; charset=utf-8',
...(init?.headers as Record<string, string> | undefined),
},
});
}
function request(
pathname: string,
method = 'GET',
body?: Record<string, unknown>,
): Request {
return new Request(`http://localhost${pathname}`, {
method,
body: body === undefined ? undefined : JSON.stringify(body),
headers:
body === undefined ? undefined : { 'content-type': 'application/json' },
});
}
const modelConfig: ModelConfigSnapshot = {
owner: { model: 'gpt-5.4', effort: 'medium' },
reviewer: { model: 'claude-sonnet', effort: 'high' },
arbiter: { model: 'gpt-5.4', effort: 'high' },
};
const fastMode: FastModeSnapshot = { codex: true, claude: false };
function makeClaudeAccount(): ClaudeAccountSummary {
return {
index: 0,
expiresAt: 1777348544000,
scopes: ['openid'],
exists: true,
};
}
function makeCodexAccount(): CodexAccountSummary {
return {
index: 1,
accountId: 'acct_1',
email: 'codex@example.com',
planType: 'pro',
subscriptionUntil: null,
subscriptionLastChecked: '2026-04-28T08:00:00.000Z',
exists: true,
};
}
function makeDeps(
overrides: Partial<SettingsRouteDependencies> = {},
): SettingsRouteDependencies {
return {
addClaudeAccountFromToken: () => ({ index: 2, accountId: null }),
getActiveCodexSettingsIndex: () => 1,
getFastMode: () => fastMode,
getModelConfig: () => modelConfig,
listClaudeAccounts: () => [makeClaudeAccount()],
listCodexAccounts: () => [makeCodexAccount()],
refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }),
refreshCodexAccount: async () => makeCodexAccount(),
removeAccountDirectory: () => undefined,
setActiveCodexSettingsIndex: () => undefined,
updateFastMode: () => fastMode,
updateModelConfig: () => modelConfig,
...overrides,
};
}
async function route(
pathname: string,
method = 'GET',
body?: Record<string, unknown>,
deps: SettingsRouteDependencies = makeDeps(),
): Promise<Response | null> {
return handleSettingsRoute({
url: new URL(`http://localhost${pathname}`),
request: request(pathname, method, body),
jsonResponse,
deps,
});
}
describe('web dashboard settings routes', () => {
it('serves settings snapshots without touching the server router', async () => {
const accounts = await route('/api/settings/accounts');
expect(accounts?.status).toBe(200);
await expect(accounts?.json()).resolves.toMatchObject({
claude: [{ index: 0, exists: true }],
codex: [{ index: 1, email: 'codex@example.com' }],
codexCurrentIndex: 1,
});
const models = await route('/api/settings/models');
expect(models?.status).toBe(200);
await expect(models?.json()).resolves.toEqual(modelConfig);
const mode = await route('/api/settings/fast-mode');
expect(mode?.status).toBe(200);
await expect(mode?.json()).resolves.toEqual(fastMode);
const unmatched = await route('/api/overview');
expect(unmatched).toBeNull();
});
it('handles settings mutations through injected dependencies', async () => {
const calls: string[] = [];
const deps = makeDeps({
addClaudeAccountFromToken: (token) => {
calls.push(`add:${token}`);
return { index: 3, accountId: null };
},
removeAccountDirectory: (provider, index) => {
calls.push(`delete:${provider}:${index}`);
},
setActiveCodexSettingsIndex: (index) => {
calls.push(`current:${index}`);
},
updateModelConfig: (input) => {
calls.push(`models:${JSON.stringify(input)}`);
return modelConfig;
},
});
const modelUpdate = await route(
'/api/settings/models',
'PATCH',
{ owner: { model: 'gpt-5.5' } },
deps,
);
expect(modelUpdate?.status).toBe(200);
const add = await route(
'/api/settings/accounts/claude',
'POST',
{ token: ' claude-token ' },
deps,
);
expect(add?.status).toBe(200);
await expect(add?.json()).resolves.toMatchObject({ ok: true, index: 3 });
const del = await route(
'/api/settings/accounts/codex/4',
'DELETE',
undefined,
deps,
);
expect(del?.status).toBe(200);
const current = await route(
'/api/settings/accounts/codex/current',
'PUT',
{ index: 4 },
deps,
);
expect(current?.status).toBe(200);
await expect(current?.json()).resolves.toMatchObject({
ok: true,
codexCurrentIndex: 1,
});
expect(calls).toEqual([
'models:{"owner":{"model":"gpt-5.5"}}',
'add:claude-token',
'delete:codex:4',
'current:4',
]);
});
});

View File

@@ -0,0 +1,285 @@
import {
addClaudeAccountFromToken,
getActiveCodexSettingsIndex,
getFastMode,
getModelConfig,
listClaudeAccounts,
listCodexAccounts,
refreshAllCodexAccounts,
refreshCodexAccount,
removeAccountDirectory,
setActiveCodexSettingsIndex,
updateFastMode,
updateModelConfig,
type ClaudeAccountSummary,
type CodexAccountSummary,
type FastModeSnapshot,
type ModelConfigSnapshot,
} from './settings-store.js';
type JsonResponse = (
value: unknown,
init?: ResponseInit,
request?: Request,
) => Response;
export interface SettingsRouteDependencies {
addClaudeAccountFromToken: typeof addClaudeAccountFromToken;
getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex;
getFastMode: typeof getFastMode;
getModelConfig: typeof getModelConfig;
listClaudeAccounts: typeof listClaudeAccounts;
listCodexAccounts: typeof listCodexAccounts;
refreshAllCodexAccounts: typeof refreshAllCodexAccounts;
refreshCodexAccount: typeof refreshCodexAccount;
removeAccountDirectory: typeof removeAccountDirectory;
setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex;
updateFastMode: typeof updateFastMode;
updateModelConfig: typeof updateModelConfig;
}
interface SettingsRouteContext {
url: URL;
request: Request;
jsonResponse: JsonResponse;
deps?: SettingsRouteDependencies;
}
const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
addClaudeAccountFromToken,
getActiveCodexSettingsIndex,
getFastMode,
getModelConfig,
listClaudeAccounts,
listCodexAccounts,
refreshAllCodexAccounts,
refreshCodexAccount,
removeAccountDirectory,
setActiveCodexSettingsIndex,
updateFastMode,
updateModelConfig,
};
function readMethod(method: string): boolean {
return method === 'GET' || method === 'HEAD';
}
async function readJsonObject(
request: Request,
jsonResponse: JsonResponse,
): Promise<Record<string, unknown> | Response> {
let body: unknown = null;
try {
body = await request.json();
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return jsonResponse(
{ error: 'Body must be a JSON object' },
{ status: 400 },
);
}
return body as Record<string, unknown>;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
async function handleModelSettingsRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Promise<Response | null> {
if (readMethod(request.method)) return jsonResponse(deps.getModelConfig());
if (request.method !== 'PUT' && request.method !== 'PATCH') return null;
const body = await readJsonObject(request, jsonResponse);
if (body instanceof Response) return body;
try {
return jsonResponse(deps.updateModelConfig(body));
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
}
}
async function handleFastModeRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Promise<Response | null> {
if (readMethod(request.method)) return jsonResponse(deps.getFastMode());
if (request.method !== 'PUT' && request.method !== 'PATCH') return null;
const body = await readJsonObject(request, jsonResponse);
if (body instanceof Response) return body;
try {
return jsonResponse(deps.updateFastMode(body));
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
}
}
function handleAccountsRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Response | null {
if (!readMethod(request.method)) return null;
return jsonResponse({
claude: deps.listClaudeAccounts(),
codex: deps.listCodexAccounts(),
codexCurrentIndex: deps.getActiveCodexSettingsIndex(),
});
}
async function handleClaudeAccountAddRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Promise<Response | null> {
if (request.method !== 'POST') return null;
let body: { token?: unknown } | null = null;
try {
body = (await request.json()) as { token?: unknown };
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
const token = typeof body?.token === 'string' ? body.token.trim() : '';
if (!token)
return jsonResponse({ error: 'token is required' }, { status: 400 });
try {
const result = deps.addClaudeAccountFromToken(token);
return jsonResponse({ ok: true, ...result });
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 400 });
}
}
function handleAccountDeleteRoute(
request: Request,
accountMatch: RegExpMatchArray,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Response | null {
if (request.method !== 'DELETE') return null;
const provider = accountMatch[1] as 'claude' | 'codex';
const index = Number.parseInt(accountMatch[2], 10);
try {
deps.removeAccountDirectory(provider, index);
return jsonResponse({ ok: true, provider, index });
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 400 });
}
}
async function handleCodexRefreshRoute(
request: Request,
refreshMatch: RegExpMatchArray,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Promise<Response | null> {
if (request.method !== 'POST') return null;
const index = Number.parseInt(refreshMatch[1], 10);
try {
const updated = await deps.refreshCodexAccount(index);
return jsonResponse({ ok: true, account: updated });
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 400 });
}
}
async function handleCodexRefreshAllRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Promise<Response | null> {
if (request.method !== 'POST') return null;
try {
const result = await deps.refreshAllCodexAccounts();
return jsonResponse({ ok: true, ...result });
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
}
}
async function handleCodexCurrentRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Promise<Response | null> {
if (request.method !== 'PUT') return null;
let body: { index?: unknown } | null = null;
try {
body = (await request.json()) as { index?: unknown };
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
const idx = typeof body?.index === 'number' ? body.index : Number.NaN;
if (!Number.isInteger(idx)) {
return jsonResponse({ error: 'index must be an integer' }, { status: 400 });
}
try {
deps.setActiveCodexSettingsIndex(idx);
return jsonResponse({
ok: true,
codexCurrentIndex: deps.getActiveCodexSettingsIndex(),
});
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 400 });
}
}
export async function handleSettingsRoute({
url,
request,
jsonResponse,
deps = defaultSettingsRouteDependencies,
}: SettingsRouteContext): Promise<Response | null> {
if (url.pathname === '/api/settings/accounts') {
return handleAccountsRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/models') {
return handleModelSettingsRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/fast-mode') {
return handleFastModeRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/accounts/claude') {
return handleClaudeAccountAddRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/accounts/codex/refresh-all') {
return handleCodexRefreshAllRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/accounts/codex/current') {
return handleCodexCurrentRoute(request, jsonResponse, deps);
}
const accountMatch = url.pathname.match(
/^\/api\/settings\/accounts\/(claude|codex)\/(\d+)$/,
);
if (accountMatch) {
return handleAccountDeleteRoute(request, accountMatch, jsonResponse, deps);
}
const refreshMatch = url.pathname.match(
/^\/api\/settings\/accounts\/codex\/(\d+)\/refresh$/,
);
if (refreshMatch) {
return handleCodexRefreshRoute(request, refreshMatch, jsonResponse, deps);
}
return null;
}
export type {
ClaudeAccountSummary,
CodexAccountSummary,
FastModeSnapshot,
ModelConfigSnapshot,
};