diff --git a/apps/web/__tests__/components/backup-restore/file_test.ts b/apps/web/__tests__/components/backup-restore/file_test.ts index 2bf5bed3f0..90c5632487 100644 --- a/apps/web/__tests__/components/backup-restore/file_test.ts +++ b/apps/web/__tests__/components/backup-restore/file_test.ts @@ -11,6 +11,7 @@ const data = { searchUsage: [], performanceIncluded: false, searchConfig: null, + siteSettings: { name: 'Floway' }, }; const backup = (overrides: Record = {}) => JSON.stringify({ diff --git a/apps/web/__tests__/components/site-branding_test.tsx b/apps/web/__tests__/components/site-branding_test.tsx new file mode 100644 index 0000000000..7c0b7d3a23 --- /dev/null +++ b/apps/web/__tests__/components/site-branding_test.tsx @@ -0,0 +1,38 @@ +import { screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { DocumentTitleSync } from '../../src/components/document-title-sync'; +import { FlowayLogo } from '../../src/components/logo'; +import { SiteSettingsProvider } from '../../src/components/site-settings-context'; +import { renderInApp } from '../render'; + +afterEach(() => { + document.title = ''; +}); + +describe('site branding outlets', () => { + it('changes the wordmark without replacing its mark', () => { + const view = renderInApp(<> + + + ); + + expect(screen.getByText('Floway')).toBeTruthy(); + expect(screen.getByText('My Gateway')).toBeTruthy(); + const marks = [...view.container.querySelectorAll('img')]; + expect(marks).toHaveLength(2); + expect(marks[0].getAttribute('src')).toBe(marks[1].getAttribute('src')); + }); + + it('uses the configured name in the browser title', async () => { + const router = createMemoryRouter([{ + path: '*', + Component: () => , + }], { initialEntries: ['/dashboard'] }); + + renderInApp(); + + await waitFor(() => expect(document.title).toBe('Dashboard | My Gateway')); + }); +}); diff --git a/apps/web/src/api/site-settings.ts b/apps/web/src/api/site-settings.ts new file mode 100644 index 0000000000..c0fcc89b8b --- /dev/null +++ b/apps/web/src/api/site-settings.ts @@ -0,0 +1,12 @@ +import type { InferResponseType } from 'hono/client'; + +import { api, callApi, type ApiResult } from './client'; + +export type SiteSettings = InferResponseType; +type UpdateSiteSettingsResponse = InferResponseType; + +export const getSiteSettings = (): Promise> => + callApi(() => api.api['site-settings'].$get()); + +export const updateSiteSettings = (settings: SiteSettings): Promise> => + callApi(() => api.api['site-settings'].$put({ json: settings })); diff --git a/apps/web/src/components/backup-restore/file.ts b/apps/web/src/components/backup-restore/file.ts index a7ec7104d6..7093c8ef2f 100644 --- a/apps/web/src/components/backup-restore/file.ts +++ b/apps/web/src/components/backup-restore/file.ts @@ -6,7 +6,7 @@ import { errorMessage } from '../../lib/error-message'; // Annotated with the gateway's own literal so a bump there fails this // assignment instead of silently rejecting every backup the deployment writes. -export const BACKUP_FILE_VERSION: InferResponseType['version'] = 20; +export const BACKUP_FILE_VERSION: InferResponseType['version'] = 21; const backupFileSchema = z.object({ version: z.literal(BACKUP_FILE_VERSION), @@ -21,6 +21,7 @@ const backupFileSchema = z.object({ performance: z.array(z.unknown()).optional(), performanceIncluded: z.boolean(), searchConfig: z.unknown(), + siteSettings: z.unknown(), }).strict().superRefine((data, ctx) => { if (data.performanceIncluded !== (data.performance !== undefined)) { ctx.addIssue({ diff --git a/apps/web/src/components/document-title-sync.tsx b/apps/web/src/components/document-title-sync.tsx index 39703b9d14..0df05f9720 100644 --- a/apps/web/src/components/document-title-sync.tsx +++ b/apps/web/src/components/document-title-sync.tsx @@ -3,6 +3,7 @@ import { useLocation } from 'react-router'; import { useTranslation } from '../i18n/translation'; import { pageLabelKeys } from './sidebar/pages'; +import { useSiteSettings } from './site-settings-context'; const titleKeyForPathname = (pathname: string) => { if (pathname === '/') return 'auth.login.title'; @@ -24,11 +25,12 @@ const titleKeyForPathname = (pathname: string) => { export function DocumentTitleSync() { const location = useLocation(); const { i18n, t } = useTranslation(); + const { name } = useSiteSettings(); useEffect(() => { const title = t(titleKeyForPathname(location.pathname)); - window.document.title = t('app.documentTitle', { title }); - }, [i18n.language, location.pathname, t]); + window.document.title = t('app.documentTitle', { siteName: name, title }); + }, [i18n.language, location.pathname, name, t]); return null; } diff --git a/apps/web/src/components/logo.tsx b/apps/web/src/components/logo.tsx index 503598f5c9..64346d7aff 100644 --- a/apps/web/src/components/logo.tsx +++ b/apps/web/src/components/logo.tsx @@ -44,7 +44,7 @@ const useMarkStyles = makeStyles({ glyph: { display: 'block', height: '24px', width: '24px' }, }); -export function FlowayLogo() { +export function FlowayLogo({ name = 'Floway' }: { name?: string }) { const ms = useMarkStyles(); const mark = currentMark(); @@ -60,7 +60,7 @@ export function FlowayLogo() { - Floway + {name} ); diff --git a/apps/web/src/components/sidebar/nav.tsx b/apps/web/src/components/sidebar/nav.tsx index 41a6ae16a6..9c95e694ed 100644 --- a/apps/web/src/components/sidebar/nav.tsx +++ b/apps/web/src/components/sidebar/nav.tsx @@ -87,7 +87,7 @@ function SidebarLink({ children, icon, onNavigate, pending, to }: { const AccountIcon = accountPage.icon; -export function Sidebar({ onNavigate, user }: { onNavigate?: () => void; user: AuthUser }) { +export function Sidebar({ onNavigate, siteName, user }: { onNavigate?: () => void; siteName: string; user: AuthUser }) { const { t } = useTranslation(); const { pathname } = useLocation(); const navigation = useNavigation(); @@ -127,7 +127,7 @@ export function Sidebar({ onNavigate, user }: { onNavigate?: () => void; user: A >
- + {onNavigate &&
diff --git a/apps/web/src/components/sidebar/pages.ts b/apps/web/src/components/sidebar/pages.ts index eed5d99284..986f9422a4 100644 --- a/apps/web/src/components/sidebar/pages.ts +++ b/apps/web/src/components/sidebar/pages.ts @@ -1,5 +1,6 @@ import { Chat20Color, + BuildingHome20Color, Clipboard20Color, Cloud20Color, Database20Color, @@ -72,6 +73,7 @@ export const navGroups: NavGroup[] = [ adminOnly: true, items: [ { to: '/dashboard/admin/users', labelKey: 'dashboard.nav.users', icon: People20Color }, + { to: '/dashboard/admin/site', labelKey: 'dashboard.nav.site', icon: BuildingHome20Color }, { to: '/dashboard/admin/backup-restore', labelKey: 'dashboard.nav.backupRestore', icon: Database20Color }, ], }, diff --git a/apps/web/src/components/site-settings-context.tsx b/apps/web/src/components/site-settings-context.tsx new file mode 100644 index 0000000000..880aea21c1 --- /dev/null +++ b/apps/web/src/components/site-settings-context.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; + +import type { SiteSettings } from '../api/site-settings'; + +const SiteSettingsContext = createContext(null); + +export function SiteSettingsProvider({ children, value }: { children: React.ReactNode; value: SiteSettings }) { + return {children}; +} + +export function useSiteSettings(): SiteSettings { + const settings = useContext(SiteSettingsContext); + if (!settings) throw new Error('useSiteSettings must be used within SiteSettingsProvider'); + return settings; +} diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 600237cf15..110c7012f0 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -4,7 +4,7 @@ const en = { translation: { app: { title: 'Floway', - documentTitle: '{{title}} | Floway', + documentTitle: '{{title}} | {{siteName}}', }, common: { loading: shellLoadingLabel, @@ -81,6 +81,7 @@ const en = { usage: 'Usage', performance: 'Performance', users: 'Users', + site: 'Site', backupRestore: 'Backup / Restore', settings: 'Settings', }, @@ -107,10 +108,24 @@ const en = { 'Monitor latency, throughput, and upstream performance signals', users: 'Manage console users, permissions, telemetry access, and upstream scopes', + site: + 'Choose the site name shown in the dashboard and browser title', backupRestore: 'Download a full copy of everything this gateway holds, or restore it from an earlier export', unavailable: 'This view could not be loaded', }, + siteSettings: { + heading: 'Site identity', + description: 'The mark stays the same; only the name beside it and in browser titles changes.', + name: 'Site name', + nameHint: 'Use 1 to 64 characters.', + save: 'Save', + saved: 'Site name updated', + validation: { + required: 'Enter a site name.', + max: 'Site name must be 64 characters or fewer.', + }, + }, playground: { system: 'Custom system prompt', systemPlaceholder: 'Optional instructions for the model', diff --git a/apps/web/src/i18n/locales/zh-Hans.ts b/apps/web/src/i18n/locales/zh-Hans.ts index a2383fba15..8c95a254e9 100644 --- a/apps/web/src/i18n/locales/zh-Hans.ts +++ b/apps/web/src/i18n/locales/zh-Hans.ts @@ -2,7 +2,7 @@ const zhHansCN = { translation: { app: { title: 'Floway', - documentTitle: '{{title}} | Floway', + documentTitle: '{{title}} | {{siteName}}', }, common: { loading: '加载中…', @@ -79,6 +79,7 @@ const zhHansCN = { usage: '使用量', performance: '性能', users: '用户', + site: '站点', backupRestore: '备份 / 恢复', settings: '设置', }, @@ -98,9 +99,22 @@ const zhHansCN = { usage: '按用户、API 密钥、模型和上游查看 token 用量与流量', performance: '观察延迟、吞吐和上游性能信号', users: '管理控制台用户、权限、观测访问和上游范围', + site: '设置 Dashboard 左上角和浏览器标题中显示的站点名称', backupRestore: '下载此网关全部数据的完整副本,或从此前的导出中恢复', unavailable: '无法加载此视图', }, + siteSettings: { + heading: '站点标识', + description: '图标保持不变,只修改旁边的名称和浏览器标题中的名称。', + name: '站点名称', + nameHint: '可输入 1 至 64 个字符。', + save: '保存', + saved: '站点名称已更新', + validation: { + required: '请输入站点名称。', + max: '站点名称不能超过 64 个字符。', + }, + }, playground: { system: '自定义系统提示词', systemPlaceholder: '可选的模型指令', diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index bfd3a493c5..34a03f2278 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -9,11 +9,13 @@ import criticalCss from 'virtual:floway-critical.css?inline'; import winuiStylesheet from 'virtual:floway-winui.css?url'; import type { Route } from './+types/root'; +import { getSiteSettings } from './api/site-settings'; import { BrowserLanguageSync } from './components/browser-language-sync'; import { DocumentTitleSync } from './components/document-title-sync'; import { GradientBackground } from './components/gradient-background'; import { markPickerScript } from './components/logo-mark'; import { NavigationProgress } from './components/navigation-progress'; +import { SiteSettingsProvider } from './components/site-settings-context'; import { ErrorShell, ErrorStack } from './components/ui/error-shell'; import { AppLoadingScreen } from './components/ui/loading-screen'; import { fluentComponents } from './fluent'; @@ -82,13 +84,19 @@ export function Layout({ children }: { children: React.ReactNode }) { ); } -export default function App() { +export async function clientLoader() { + const result = await getSiteSettings(); + if (result.error) throw new Error(result.error.message, { cause: result.error.cause }); + return result.data; +} + +export default function App({ loaderData }: Route.ComponentProps) { return ( - <> + - + ); } diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index dee15a6437..54e0610a6c 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -37,6 +37,7 @@ export default [ route('monitor/usage', 'routes/dashboard-monitor-usage.tsx'), route('monitor/performance', 'routes/dashboard-monitor-performance.tsx'), route('admin/users', 'routes/dashboard-admin-users.tsx'), + route('admin/site', 'routes/dashboard-admin-site.tsx'), route('admin/backup-restore', 'routes/dashboard-admin-backup-restore.tsx'), route('settings', 'routes/dashboard-settings.tsx'), ...developmentRoutes, diff --git a/apps/web/src/routes/dashboard-admin-site.tsx b/apps/web/src/routes/dashboard-admin-site.tsx new file mode 100644 index 0000000000..4522a42bbb --- /dev/null +++ b/apps/web/src/routes/dashboard-admin-site.tsx @@ -0,0 +1,95 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useRevalidator } from 'react-router'; +import { z } from 'zod'; + +import { requireDashboardAdmin } from './guards'; +import { updateSiteSettings } from '../api/site-settings'; +import { useSiteSettings } from '../components/site-settings-context'; +import { DashboardPageHeader } from '../components/ui/dashboard-page-header'; +import { Input } from '../components/ui/fluent-form-controls'; +import { PANEL_STACK_CLASS } from '../components/ui/layout'; +import { OutcomeMessageBar } from '../components/ui/outcome-message-bar'; +import { useOutcomeToasts } from '../components/ui/outcome-toast'; +import { Panel } from '../components/ui/panel'; +import { SectionHeader } from '../components/ui/section-header'; +import { fluentComponents } from '../fluent'; +import { useTranslation } from '../i18n/translation'; + +const { Button, Field } = fluentComponents; + +export async function clientLoader() { + await requireDashboardAdmin(); + return null; +} + +const siteNameSchema = z.object({ + name: z.string() + .trim() + .min(1, 'dashboard.siteSettings.validation.required') + .max(64, 'dashboard.siteSettings.validation.max'), +}); + +type SiteNameFormValues = z.infer; + +export default function DashboardAdminSite() { + const { name } = useSiteSettings(); + const { t } = useTranslation(); + const revalidator = useRevalidator(); + const toasts = useOutcomeToasts(); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const { + formState: { errors }, + handleSubmit, + register, + } = useForm({ + resolver: zodResolver(siteNameSchema), + values: { name }, + }); + + const save = async (values: SiteNameFormValues) => { + if (saving) return; + setSaving(true); + setError(null); + const result = await updateSiteSettings(values); + if (result.error) { + setError(result.error.message); + setSaving(false); + return; + } + await revalidator.revalidate(); + setSaving(false); + toasts.succeed(t('dashboard.siteSettings.saved')); + }; + + return ( +
+ + + + + +
void handleSubmit(save)(event)}> + + + + + {error && setError(null)}>{error}} + +
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index 91aa581daa..32d9851574 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -15,6 +15,7 @@ import type { AuthUser } from '../api/auth'; import { FlowayLogo } from '../components/logo'; import { usePageFrames } from '../components/page-frames'; import { Sidebar } from '../components/sidebar/nav'; +import { useSiteSettings } from '../components/site-settings-context'; import { SCROLLPORT_FILL_CLASS } from '../components/ui/layout'; import { OutcomeToastProvider } from '../components/ui/outcome-toast'; import { ScrollArea } from '../components/ui/scroll-area'; @@ -49,6 +50,7 @@ export default function Dashboard({}: Route.ComponentProps) { function DashboardShell({ user }: { user: AuthUser }) { const { t } = useTranslation(); + const { name: siteName } = useSiteSettings(); const [navigationOpen, setNavigationOpen] = useState(false); // The entrance is started on the element, not declared in the sheet; // ../winui/page-transition.css.ts says why. React state and a deliberate @@ -101,7 +103,7 @@ function DashboardShell({ user }: { user: AuthUser }) {
- +
{frames.map(frame =>
- setNavigationOpen(false)} user={user} /> + setNavigationOpen(false)} siteName={siteName} user={user} /> diff --git a/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts b/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts index 41c29fca02..0bedfe0827 100644 --- a/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts +++ b/packages/gateway/__tests__/control-plane/data-transfer/routes_test.ts @@ -328,7 +328,7 @@ const doExport = async (app: Hono, includePerformance = false) => { return (await resp.json()) as Record; }; -const doImport = async (app: Hono, mode: string, data: unknown, version: unknown = 20) => { +const doImport = async (app: Hono, mode: string, data: unknown, version: unknown = 21) => { const resp = await app.request('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -345,6 +345,7 @@ const latestImportData = (overrides: Record = {}) => ({ searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, + siteSettings: { name: 'Floway' }, ...overrides, }); @@ -366,13 +367,13 @@ test('import validates generic pricing selectors', async () => { assertEquals(String(fractional.body.error).includes('positive safe integer'), true); }); -test('export emits the v20 envelope with users and upstreams', async () => { +test('export emits the v21 envelope with users and upstreams', async () => { const { app, repo } = setup(); await repo.users.save(SEED_ADMIN); const result = await doExport(app); - assertEquals(result.version, 20); + assertEquals(result.version, 21); assertEquals(typeof result.exportedAt, 'string'); assertEquals(result.data.users, [SEED_ADMIN]); assertEquals(result.data.apiKeys, []); @@ -383,6 +384,7 @@ test('export emits the v20 envelope with users and upstreams', async () => { assertEquals(result.data.performanceIncluded, false); assertEquals(hasOwn(result.data, 'performance'), false); assertEquals(result.data.searchConfig, DEFAULT_WEB_SEARCH_CONFIG); + assertEquals(result.data.siteSettings, { name: 'Floway' }); assertEquals(hasOwn(result.data, 'githubAccounts'), false); assertEquals(hasOwn(result.data, 'upstreamConfigs'), false); }); @@ -403,6 +405,7 @@ test('export includes full upstream configs and omits performance by default', a jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }); + await repo.siteSettings.save({ name: 'My Gateway' }); const result = await doExport(app); @@ -420,6 +423,15 @@ test('export includes full upstream configs and omits performance by default', a assertEquals(result.data.performanceIncluded, false); assertEquals(hasOwn(result.data, 'performance'), false); assertEquals(result.data.searchConfig.provider, 'tavily'); + assertEquals(result.data.siteSettings, { name: 'My Gateway' }); +}); + +test('import restores site settings', async () => { + const { app, repo } = setup(); + const result = await doImport(app, 'replace', latestImportData({ siteSettings: { name: 'Restored Gateway' } })); + + assertEquals(result.status, 200); + assertEquals(await repo.siteSettings.get(), { name: 'Restored Gateway' }); }); test('export includes performance only when requested', async () => { @@ -441,8 +453,8 @@ test('import rejects any version other than the current one before deleting data await repo.apiKeys.save(KEY_A); await repo.upstreams.save(CUSTOM_UPSTREAM); - const VERSION_ERROR = 'version must be 20 — older export formats are not supported; re-export from the current deployment'; - const previousV19 = await doImport(app, 'replace', latestImportData(), 19); + const VERSION_ERROR = 'version must be 21 — older export formats are not supported; re-export from the current deployment'; + const previousV20 = await doImport(app, 'replace', latestImportData(), 20); const previousV11 = await doImport(app, 'replace', latestImportData(), 11); const ancientVersion = await doImport(app, 'replace', { apiKeys: [] }, 1); const missingVersionResponse = await app.request('/import', { @@ -452,8 +464,8 @@ test('import rejects any version other than the current one before deleting data }); const missingVersion = { status: missingVersionResponse.status, body: (await missingVersionResponse.json()) as Record }; - assertEquals(previousV19.status, 400); - assertEquals(previousV19.body.error, VERSION_ERROR); + assertEquals(previousV20.status, 400); + assertEquals(previousV20.body.error, VERSION_ERROR); assertEquals(previousV11.status, 400); assertEquals(previousV11.body.error, VERSION_ERROR); assertEquals(ancientVersion.status, 400); @@ -493,6 +505,7 @@ test('import replace writes upstreams and clears replaced collections', async () jina: { apiKey: '' }, passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' }, }, + siteSettings: { name: 'Imported Gateway' }, }); assertEquals(result.status, 200); @@ -575,6 +588,7 @@ test('import replace handles performance inclusion explicitly', async () => { performanceIncluded: true, performance: [PERFORMANCE_2], searchConfig: DEFAULT_WEB_SEARCH_CONFIG, + siteSettings: { name: 'Floway' }, }); assertEquals(replace.status, 200); @@ -705,6 +719,7 @@ test('import rejects missing upstreams before clearing existing data', async () searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, + siteSettings: { name: 'Floway' }, }); assertEquals(result.status, 400); @@ -731,6 +746,7 @@ test('ollama upstreams export and import round-trip', async () => { searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, + siteSettings: { name: 'Floway' }, }); assertEquals(replaceResult.status, 200); assertEquals(await repo.upstreams.list(), [OLLAMA_UPSTREAM]); @@ -754,6 +770,7 @@ test('codex upstreams export and import round-trip with state intact', async () searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, + siteSettings: { name: 'Floway' }, }); assertEquals(replaceResult.status, 200); assertEquals(await repo.upstreams.list(), [CODEX_UPSTREAM]); @@ -804,7 +821,7 @@ test('import rejects negative historical unit prices with a metric-specific erro assertEquals(result.body.error, 'invalid usage at index 0: metric unitPrice must be non-negative: "-0.01"'); }); -test('v20 import validates usage metric rows', async () => { +test('v21 import validates usage metric rows', async () => { const { app } = setup(); const missingMetrics = await doImport(app, 'replace', latestImportData({ usage: [{ ...USAGE_2, metrics: undefined }], @@ -976,7 +993,7 @@ test('import trims every formerly normalized non-empty string field', async () = assertEquals(whitespaceOnly.body.error, 'invalid apiKeys at index 0: key must be a non-empty string'); }); -test('import retains optional defaults from the v20 wire contract', async () => { +test('import retains optional defaults from the v21 wire contract', async () => { const { app, repo } = setup(); const { disabled_public_model_ids: _disabled, model_prefix: _prefix, ...upstream } = upstreamRecordToFullJson(CUSTOM_UPSTREAM); const result = await doImport(app, 'replace', latestImportData({ @@ -1160,7 +1177,7 @@ test('import preserves a positive dumpRetentionSeconds on api keys', async () => assertEquals(restored?.dumpRetentionSeconds, 3600); }); -test('v20 import preserves and validates Responses retention', async () => { +test('v21 import preserves and validates Responses retention', async () => { const { app, repo } = setup(); const retained = await doImport(app, 'replace', latestImportData({ apiKeys: [{ ...KEY_A, responsesRetentionSeconds: 7 * 24 * 60 * 60 }], @@ -1246,7 +1263,7 @@ test('import rejects legacy enabled_fixes payloads before mutating', async () => assertEquals(await repo.upstreams.list(), [CUSTOM_UPSTREAM]); }); -test('import rejects missing latest-v20 arrays before clearing existing data', async () => { +test('import rejects missing latest-v21 arrays before clearing existing data', async () => { const { app, repo } = setup(); await repo.apiKeys.save(KEY_A); await repo.upstreams.save(CUSTOM_UPSTREAM); @@ -1272,14 +1289,14 @@ test('import rejects missing latest-v20 arrays before clearing existing data', a test('import validates mode and data before mutating', async () => { const { app } = setup(); - const invalidMode = await doImport(app, 'invalid', {}, 20); + const invalidMode = await doImport(app, 'invalid', {}, 21); const missingData = await app.request('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'replace', version: 20 }), + body: JSON.stringify({ mode: 'replace', version: 21 }), }); - const missingUpstreams = await doImport(app, 'merge', {}, 20); - const emptyMerge = await doImport(app, 'merge', latestImportData(), 20); + const missingUpstreams = await doImport(app, 'merge', {}, 21); + const emptyMerge = await doImport(app, 'merge', latestImportData(), 21); assertEquals(invalidMode.status, 400); assertEquals(invalidMode.body.error, "mode must be 'merge' or 'replace'"); @@ -1431,7 +1448,7 @@ test('import replace wipes proxy_upstream_backoffs alongside the proxies it cool assertEquals(await repo.proxyBackoffs.listAll(), []); }); -test('v20 export/import round-trips users and per-key user_id', async () => { +test('v21 export/import round-trips users and per-key user_id', async () => { const { app, repo } = setup(); await repo.users.save(SEED_ADMIN); await repo.users.save(USER_BOB); @@ -1439,10 +1456,10 @@ test('v20 export/import round-trips users and per-key user_id', async () => { await repo.apiKeys.save({ ...KEY_B, userId: USER_BOB.id }); const exportResult = await doExport(app); - assertEquals(exportResult.version, 20); + assertEquals(exportResult.version, 21); assertEquals(exportResult.data.users.map((u: any) => u.id).sort(), [SEED_ADMIN.id, USER_BOB.id]); - const result = await doImport(app, 'replace', exportResult.data, 20); + const result = await doImport(app, 'replace', exportResult.data, 21); assertEquals(result.status, 200); assertEquals(result.body.imported.users, 2); assertEquals(result.body.imported.apiKeys, 2); @@ -1453,7 +1470,7 @@ test('v20 export/import round-trips users and per-key user_id', async () => { assertEquals(restoredKey?.userId, USER_BOB.id); }); -test('v20 import rejects api_keys whose user_id does not appear in the payload', async () => { +test('v21 import rejects api_keys whose user_id does not appear in the payload', async () => { const { app, repo } = setup(); await repo.users.save(SEED_ADMIN); @@ -1465,13 +1482,13 @@ test('v20 import rejects api_keys whose user_id does not appear in the payload', searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 20); + }, 21); assertEquals(result.status, 400); assertEquals(result.body.error, 'invalid apiKeys at index 0: user_id 99 does not match any user in the payload'); }); -test('v20 import rejects malformed users (bad username, bad password_hash)', async () => { +test('v21 import rejects malformed users (bad username, bad password_hash)', async () => { const { app } = setup(); const badUsername = await doImport(app, 'replace', { @@ -1482,7 +1499,7 @@ test('v20 import rejects malformed users (bad username, bad password_hash)', asy searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 20); + }, 21); assertEquals(badUsername.status, 400); assertEquals(String(badUsername.body.error).startsWith('invalid users at index 0:'), true); @@ -1494,7 +1511,7 @@ test('v20 import rejects malformed users (bad username, bad password_hash)', asy searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 20); + }, 21); assertEquals(badHash.status, 400); assertEquals(String(badHash.body.error).includes('passwordHash'), true); }); @@ -1516,7 +1533,7 @@ test('import rejects a pre-accounts v3 export instead of coercing its legacy api }, 3); assertEquals(result.status, 400); - assertEquals(String(result.body.error).includes('version must be 20'), true); + assertEquals(String(result.body.error).includes('version must be 21'), true); // Rejected at the version gate, before touching any data. assertEquals(await repo.apiKeys.list(), [KEY_A]); assertEquals((await repo.users.list()).map(u => u.id), [SEED_ADMIN.id]); @@ -1537,7 +1554,8 @@ test('replace-mode import clears sessions before writing users', async () => { searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 20); + siteSettings: { name: 'Floway' }, + }, 21); assertEquals(result.status, 200); // No public listAll on sessions; create a fresh session and check the @@ -1546,7 +1564,7 @@ test('replace-mode import clears sessions before writing users', async () => { assertEquals(await repo.sessions.deleteByUserId(USER_BOB.id), 0); }); -test('v20 import rejects users[i].upstreamIds === undefined', async () => { +test('v21 import rejects users[i].upstreamIds === undefined', async () => { const { app } = setup(); const result = await doImport(app, 'replace', { users: [SEED_ADMIN, { ...USER_BOB, upstreamIds: undefined }], @@ -1556,12 +1574,12 @@ test('v20 import rejects users[i].upstreamIds === undefined', async () => { searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 20); + }, 21); assertEquals(result.status, 400); expect(result.body.error).toMatch(/upstreamIds/); }); -test('v20 import rejects users[i].deletedAt of non-string non-null type', async () => { +test('v21 import rejects users[i].deletedAt of non-string non-null type', async () => { const { app } = setup(); const result = await doImport(app, 'replace', { users: [SEED_ADMIN, { ...USER_BOB, deletedAt: 42 }], @@ -1571,12 +1589,12 @@ test('v20 import rejects users[i].deletedAt of non-string non-null type', async searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 20); + }, 21); assertEquals(result.status, 400); expect(result.body.error).toMatch(/deletedAt/); }); -test('v20 replace import refuses payload missing user 1', async () => { +test('v21 replace import refuses payload missing user 1', async () => { const { app } = setup(); const result = await doImport(app, 'replace', { users: [USER_BOB], @@ -1586,12 +1604,12 @@ test('v20 replace import refuses payload missing user 1', async () => { searchUsage: [], performanceIncluded: false, searchConfig: DEFAULT_WEB_SEARCH_CONFIG, - }, 20); + }, 21); assertEquals(result.status, 400); expect(result.body.error).toMatch(/user 1/); }); -test('a full v20 export re-imports verbatim — the export→import round trip is closed', async () => { +test('a full v21 export re-imports verbatim — the export→import round trip is closed', async () => { const { app, repo } = setup(); await repo.users.save(SEED_ADMIN); await repo.users.save(USER_BOB); @@ -1617,12 +1635,12 @@ test('a full v20 export re-imports verbatim — the export→import round trip i await repo.webSearchConfig.save(config); const exported = await doExport(app, true); - assertEquals(exported.version, 20); + assertEquals(exported.version, 21); // Replace-import the export's own `data`, verbatim. If the export emits any // shape the import parser rejects, this 400s — the round trip is the // invariant, so this test fails the moment the two sides drift. - const result = await doImport(app, 'replace', exported.data, 20); + const result = await doImport(app, 'replace', exported.data, 21); assertEquals(result.status, 200); assertEquals(result.body.imported, { users: 2, apiKeys: 2, upstreams: 4, proxies: 0, usage: 2, searchUsage: 2, performance: 2 }); @@ -1655,10 +1673,10 @@ test('any data bearing a historical version is rejected on the version gate, bef searchConfig: DEFAULT_WEB_SEARCH_CONFIG, }; - for (let version = 1; version < 20; version++) { + for (let version = 1; version < 21; version++) { const result = await doImport(app, 'replace', wellFormed, version); assertEquals(result.status, 400); - assertEquals(String(result.body.error).includes('version must be 20'), true); + assertEquals(String(result.body.error).includes('version must be 21'), true); } // Nothing was touched — the version gate runs before any delete or write. diff --git a/packages/gateway/__tests__/control-plane/site-settings/routes_test.ts b/packages/gateway/__tests__/control-plane/site-settings/routes_test.ts new file mode 100644 index 0000000000..e21ff844f9 --- /dev/null +++ b/packages/gateway/__tests__/control-plane/site-settings/routes_test.ts @@ -0,0 +1,42 @@ +import { test } from 'vitest'; + +import { requestApp, setupAppTest } from '../../test-utils/app.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('GET /api/site-settings is public and returns the default name', async () => { + await setupAppTest(); + const response = await requestApp('/api/site-settings', {}); + + assertEquals(response.status, 200); + assertEquals(await response.json(), { name: 'Floway' }); +}); + +test('PUT /api/site-settings trims and persists a name for administrators', async () => { + const { adminSession, repo } = await setupAppTest(); + const response = await requestApp('/api/site-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json', 'x-floway-session': adminSession }, + body: JSON.stringify({ name: ' My Gateway ' }), + }); + + assertEquals(response.status, 200); + assertEquals(await response.json(), { name: 'My Gateway' }); + assertEquals(await repo.siteSettings.get(), { name: 'My Gateway' }); +}); + +test('PUT /api/site-settings requires an administrator and validates the name', async () => { + const { adminSession, apiKey } = await setupAppTest(); + const nonAdmin = await requestApp('/api/site-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json', 'x-api-key': apiKey.key }, + body: JSON.stringify({ name: 'Nope' }), + }); + const empty = await requestApp('/api/site-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json', 'x-floway-session': adminSession }, + body: JSON.stringify({ name: ' ' }), + }); + + assertEquals(nonAdmin.status, 403); + assertEquals(empty.status, 400); +}); diff --git a/packages/gateway/__tests__/repo/memory.ts b/packages/gateway/__tests__/repo/memory.ts index d2d9b4a379..021cc4fe6a 100644 --- a/packages/gateway/__tests__/repo/memory.ts +++ b/packages/gateway/__tests__/repo/memory.ts @@ -45,6 +45,8 @@ import type { ResponsesItemsRepo, ResponsesSnapshotsRepo, ScheduledMaintenanceRepo, + SiteSettings, + SiteSettingsRepo, SpilledFilesRepo, WebSearchConfigRepo, WebSearchUsageRecord, @@ -730,6 +732,19 @@ class MemoryWebSearchConfigRepo implements WebSearchConfigRepo { } } +class MemorySiteSettingsRepo implements SiteSettingsRepo { + private settings: SiteSettings = { name: 'Floway' }; + + get(): Promise { + return Promise.resolve({ ...this.settings }); + } + + save(settings: SiteSettings): Promise { + this.settings = { ...settings }; + return Promise.resolve(); + } +} + class MemoryUpstreamRepo implements UpstreamRepo { private store = new Map(); @@ -1466,6 +1481,7 @@ export class InMemoryRepo implements Repo { usage: UsageRepo; webSearchUsage: WebSearchUsageRepo; performance: PerformanceRepo; + siteSettings: SiteSettingsRepo; webSearchConfig: WebSearchConfigRepo; upstreams: UpstreamRepo; proxies: ProxyRepo; @@ -1487,6 +1503,7 @@ export class InMemoryRepo implements Repo { this.usage = new MemoryUsageRepo(this.apiKeys); this.webSearchUsage = new MemoryWebSearchUsageRepo(); this.performance = new MemoryPerformanceRepo(this.apiKeys); + this.siteSettings = new MemorySiteSettingsRepo(); this.webSearchConfig = new MemoryWebSearchConfigRepo(); this.upstreams = new MemoryUpstreamRepo(); this.proxies = new MemoryProxyRepo(this.upstreams); diff --git a/packages/gateway/__tests__/repo/site-settings_test.ts b/packages/gateway/__tests__/repo/site-settings_test.ts new file mode 100644 index 0000000000..09b491bf13 --- /dev/null +++ b/packages/gateway/__tests__/repo/site-settings_test.ts @@ -0,0 +1,22 @@ +import { test } from 'vitest'; + +import { InMemoryRepo } from './memory.ts'; +import { createSqliteTestDb } from './test-sqlite.ts'; +import { SqlRepo } from '../../src/repo/sql.ts'; +import type { SiteSettingsRepo } from '../../src/repo/types.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +const exerciseSiteSettingsRepo = async (repo: SiteSettingsRepo) => { + assertEquals(await repo.get(), { name: 'Floway' }); + await repo.save({ name: 'My Gateway' }); + assertEquals(await repo.get(), { name: 'My Gateway' }); +}; + +test('in-memory site settings repository', async () => { + await exerciseSiteSettingsRepo(new InMemoryRepo().siteSettings); +}); + +test('SQL site settings repository', async () => { + const repo = new SqlRepo(await createSqliteTestDb()); + await exerciseSiteSettingsRepo(repo.siteSettings); +}); diff --git a/packages/gateway/migrations/0083_site_settings.sql b/packages/gateway/migrations/0083_site_settings.sql new file mode 100644 index 0000000000..2752d0df91 --- /dev/null +++ b/packages/gateway/migrations/0083_site_settings.sql @@ -0,0 +1,6 @@ +CREATE TABLE site_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64) +); + +INSERT INTO site_settings (id, name) VALUES (1, 'Floway'); diff --git a/packages/gateway/src/control-plane/data-transfer/import-schema.ts b/packages/gateway/src/control-plane/data-transfer/import-schema.ts index be5453257c..c10ad29495 100644 --- a/packages/gateway/src/control-plane/data-transfer/import-schema.ts +++ b/packages/gateway/src/control-plane/data-transfer/import-schema.ts @@ -6,7 +6,7 @@ import { parseDisabledPublicModelIdsWire } from '../../repo/disabled-public-mode import { isDirectFallbackId, normalizeProxyFallbackList } from '../../repo/proxy-fallback-list.ts'; import { isResponsesRetentionSeconds, RESPONSES_RETENTION_MAX_SECONDS, RESPONSES_RETENTION_MIN_SECONDS } from '../../repo/responses-retention.ts'; import { SEED_ADMIN_USER_ID } from '../../repo/seed-admin.ts'; -import type { ApiKey, PerformanceMetric, PerformanceTelemetryRecord, UsageRecord, User, WebSearchUsageRecord } from '../../repo/types.ts'; +import type { ApiKey, PerformanceMetric, PerformanceTelemetryRecord, SiteSettings, UsageRecord, User, WebSearchUsageRecord } from '../../repo/types.ts'; import { PASSWORD_HASH_SCHEME } from '../../shared/passwords.ts'; import { RETENTION_MAX_SECONDS } from '../../shared/retention.ts'; import { parseServerSecret } from '../../shared/server-secret.ts'; @@ -41,6 +41,7 @@ export interface ParsedImportData { performance: PerformanceTelemetryRecord[]; performanceIncluded: boolean; searchConfig: WebSearchConfig; + siteSettings: SiteSettings; } export type ImportDataParseResult = { type: 'ok'; data: ParsedImportData } | { type: 'invalid'; error: string }; @@ -79,6 +80,12 @@ const objectIncludingArraySchema = (message: string) => z.custom z.string({ error: `${field} must be a string` }) .transform(value => value.trim()) .refine(value => value !== '', { error: `${field} must be a non-empty string` }); +const siteSettingsSchema = z.object({ + name: z.string({ error: 'name must be a string' }) + .trim() + .min(1, { error: 'name must be a non-empty string' }) + .max(64, { error: 'name must be at most 64 characters' }), +}, { error: 'siteSettings must be an object' }).strict(); const nonEmptyStringWithError = (message: string) => z.string({ error: message }).min(1, { error: message }); const nullableStringSchema = (field: string) => z.union([ z.string(), @@ -508,6 +515,11 @@ export const parseImportData = (value: unknown): ImportDataParseResult => { return { type: 'invalid', error: `invalid searchConfig: ${messageFor(cause)}` }; } + const siteSettingsResult = siteSettingsSchema.safeParse(value.siteSettings); + if (!siteSettingsResult.success) { + return { type: 'invalid', error: `invalid siteSettings: ${siteSettingsResult.error.issues[0].message}` }; + } + if (typeof value.performanceIncluded !== 'boolean') { return { type: 'invalid', error: 'performanceIncluded must be a boolean' }; } @@ -535,6 +547,7 @@ export const parseImportData = (value: unknown): ImportDataParseResult => { performance, performanceIncluded: value.performanceIncluded, searchConfig, + siteSettings: siteSettingsResult.data, }, }; }; diff --git a/packages/gateway/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts index b0033de2eb..f443bd0e34 100644 --- a/packages/gateway/src/control-plane/data-transfer/routes.ts +++ b/packages/gateway/src/control-plane/data-transfer/routes.ts @@ -15,14 +15,14 @@ import { notifyDisabledBestEffort } from '../../dump/registry.ts'; import { type CtxWithJson, type CtxWithQuery } from '../../middleware/zod-validator.ts'; import { getRepo } from '../../repo/index.ts'; import { DIRECT_FALLBACK_IDS } from '../../repo/proxy-fallback-list.ts'; -import type { ApiKey, PerformanceTelemetryRecord, UsageRecord, User, WebSearchUsageRecord } from '../../repo/types.ts'; +import type { ApiKey, PerformanceTelemetryRecord, SiteSettings, UsageRecord, User, WebSearchUsageRecord } from '../../repo/types.ts'; import { type exportQuery, type importBody } from '../schemas.ts'; import { warmModelsCache } from '../shared/warm-models-cache.ts'; import { type FullSerializedUpstreamRecord, upstreamRecordToFullJson } from '../upstreams/serialize.ts'; import type { UpstreamRecord } from '@floway-dev/provider'; interface ExportPayload { - version: 20; + version: 21; exportedAt: string; data: { users: User[]; @@ -34,10 +34,11 @@ interface ExportPayload { performance?: PerformanceTelemetryRecord[]; performanceIncluded: boolean; searchConfig: WebSearchConfig; + siteSettings: SiteSettings; }; } -const EXPORT_VERSION = 20; +const EXPORT_VERSION = 21; const validateApiKeyIdentities = (records: readonly ApiKey[], existing: readonly ApiKey[], mode: 'merge' | 'replace'): string | null => { const ids = new Map(); @@ -100,13 +101,14 @@ export const exportData = async (c: CtxWithQuery) => { const repo = getRepo(); const includePerformance = c.req.valid('query').include_performance === '1'; - const [users, apiKeys, usage, webSearchUsage, performance, rawWebSearchConfig, upstreams, proxies] = await Promise.all([ + const [users, apiKeys, usage, webSearchUsage, performance, rawWebSearchConfig, siteSettings, upstreams, proxies] = await Promise.all([ repo.users.listIncludingDeleted(), repo.apiKeys.listIncludingDeleted(), repo.usage.listAll(), repo.webSearchUsage.listAll(), includePerformance ? repo.performance.listAll() : Promise.resolve([]), repo.webSearchConfig.get(), + repo.siteSettings.get(), repo.upstreams.list(), repo.proxies.list(), ]); @@ -123,6 +125,7 @@ export const exportData = async (c: CtxWithQuery) => { searchUsage: webSearchUsage, performanceIncluded: includePerformance, searchConfig: rawWebSearchConfig === null ? parseWebSearchConfigDefault() : parseWebSearchConfigStrict(rawWebSearchConfig), + siteSettings, }, }; if (includePerformance) payload.data.performance = performance; @@ -134,7 +137,7 @@ export const importData = async (c: CtxWithJson) => { const { mode, data: rawData } = c.req.valid('json'); const parsed = parseImportData(rawData); if (parsed.type === 'invalid') return c.json({ error: parsed.error }, 400); - const { users, apiKeys, upstreams, proxies, usage, searchUsage, performance, performanceIncluded, searchConfig } = parsed.data; + const { users, apiKeys, upstreams, proxies, usage, searchUsage, performance, performanceIncluded, searchConfig, siteSettings } = parsed.data; const repo = getRepo(); // Merge mode needs each key's prior dump policy to identify transitions that @@ -193,6 +196,7 @@ export const importData = async (c: CtxWithJson) => { await Promise.all(upstreams.map(upstream => warmModelsCache(upstream, c))); for (const record of performance) await repo.performance.set(record); await repo.webSearchConfig.save(searchConfig); + await repo.siteSettings.save(siteSettings); return c.json({ ok: true, diff --git a/packages/gateway/src/control-plane/routes.ts b/packages/gateway/src/control-plane/routes.ts index 32978e86a0..96258ab1f3 100644 --- a/packages/gateway/src/control-plane/routes.ts +++ b/packages/gateway/src/control-plane/routes.ts @@ -9,9 +9,10 @@ import { createAlias, deleteAlias, listAliases, updateAlias } from './model-alia import { controlPlaneModels } from './models/routes.ts'; import { performanceOverview } from './performance/routes.ts'; import { createProxy, deleteProxy, listAllBackoffs, listProxies, listProxyBackoffs, resetProxyBackoffs, testProxy, updateProxy } from './proxies/routes.ts'; -import { authLoginBody, changeOwnPasswordBody, claudeCodeOAuthAuthorizeUrlBody, claudeCodeOAuthExchangeBody, claudeCodeOAuthRefreshBody, claudeCodeProbeBody, claudeCodeSetupTokenAuthorizeUrlBody, claudeCodeSetupTokenExchangeBody, codexOAuthAuthorizeUrlBody, codexOAuthExchangeBody, codexOAuthRefreshBody, copilotOAuthDeviceLoginPollBody, copilotOAuthDeviceLoginStartBody, copilotQuotaBody, createAliasBody, createKeyBody, createProxyBody, createUpstreamBody, createUserBody, exportQuery, importBody, listModelsBody, modelsQuery, ollamaUsageBody, performanceQuery, resetBackoffBody, rotateKeyBody, webSearchConfigSchema, webSearchUsageQuery, testProxyBody, tokenUsageOverviewQuery, tokenUsageQuery, updateAliasBody, updateKeyBody, updateProxyBody, updateUpstreamBody, updateUserBody } from './schemas.ts'; +import { authLoginBody, changeOwnPasswordBody, claudeCodeOAuthAuthorizeUrlBody, claudeCodeOAuthExchangeBody, claudeCodeOAuthRefreshBody, claudeCodeProbeBody, claudeCodeSetupTokenAuthorizeUrlBody, claudeCodeSetupTokenExchangeBody, codexOAuthAuthorizeUrlBody, codexOAuthExchangeBody, codexOAuthRefreshBody, copilotOAuthDeviceLoginPollBody, copilotOAuthDeviceLoginStartBody, copilotQuotaBody, createAliasBody, createKeyBody, createProxyBody, createUpstreamBody, createUserBody, exportQuery, importBody, listModelsBody, modelsQuery, ollamaUsageBody, performanceQuery, resetBackoffBody, rotateKeyBody, siteSettingsBody, webSearchConfigSchema, webSearchUsageQuery, testProxyBody, tokenUsageOverviewQuery, tokenUsageQuery, updateAliasBody, updateKeyBody, updateProxyBody, updateUpstreamBody, updateUserBody } from './schemas.ts'; import { getWebSearchConfigRoute, putWebSearchConfigRoute, testWebSearchConfigRoute } from './search-config/routes.ts'; import { webSearchUsage } from './search-usage/routes.ts'; +import { getSiteSettings, putSiteSettings } from './site-settings/routes.ts'; import { tokenUsageOverview } from './token-usage/overview.ts'; import { tokenUsage } from './token-usage/routes.ts'; import { claudeCodeOAuthAuthorizeUrl, claudeCodeOAuthExchange, claudeCodeOAuthRefresh, claudeCodeProbe, claudeCodeSetupTokenAuthorizeUrl, claudeCodeSetupTokenExchange } from './upstreams/claude-code.ts'; @@ -39,6 +40,7 @@ const adminOnlyMiddleware = async (c: AuthedContext, next: Next) => { // registered here (and inside the inner admin-gated sub-app). export const controlPlaneRoutes = new Hono<{ Variables: AuthVars }>() .get('/api/health', c => c.json({ status: 'ok', service: 'floway' })) + .get('/api/site-settings', getSiteSettings) // Quiet 204 to suppress 404 noise from favicon probes; the path is // already in PUBLIC_PATHS so auth lets it through. .get('/favicon.ico', () => new Response(null, { status: 204 })) @@ -74,6 +76,7 @@ export const controlPlaneRoutes = new Hono<{ Variables: AuthVars }>() .route('/api', new Hono<{ Variables: AuthVars }>() .use('*', adminOnlyMiddleware) .get('/users', listUsers) + .put('/site-settings', zValidator('json', siteSettingsBody), putSiteSettings) .post('/users', zValidator('json', createUserBody), createUser) .patch('/users/:id', zValidator('json', updateUserBody), updateUser) .delete('/users/:id', deleteUser) diff --git a/packages/gateway/src/control-plane/schemas.ts b/packages/gateway/src/control-plane/schemas.ts index 0ee5a742de..d22c708905 100644 --- a/packages/gateway/src/control-plane/schemas.ts +++ b/packages/gateway/src/control-plane/schemas.ts @@ -263,6 +263,12 @@ export const changeOwnPasswordBody = z.object({ newPassword: passwordSchema, }); +// --- site settings --- + +export const siteSettingsBody = z.object({ + name: z.string().trim().min(1).max(64), +}); + // --- api keys --- // `dump_retention_seconds`: null disables capture; a positive integer is the @@ -701,7 +707,7 @@ export const updateAliasBody = aliasBodyCore.superRefine(aliasBodyRulesRefinemen // --- data transfer --- export const importBody = z.object({ - version: z.literal(20, { error: 'version must be 20 — older export formats are not supported; re-export from the current deployment' }), + version: z.literal(21, { error: 'version must be 21 — older export formats are not supported; re-export from the current deployment' }), mode: z.enum(['merge', 'replace'], { error: "mode must be 'merge' or 'replace'" }), data: z.unknown().optional(), }); diff --git a/packages/gateway/src/control-plane/site-settings/routes.ts b/packages/gateway/src/control-plane/site-settings/routes.ts new file mode 100644 index 0000000000..18b09364f1 --- /dev/null +++ b/packages/gateway/src/control-plane/site-settings/routes.ts @@ -0,0 +1,13 @@ +import type { Context } from 'hono'; + +import { type CtxWithJson } from '../../middleware/zod-validator.ts'; +import { getRepo } from '../../repo/index.ts'; +import type { siteSettingsBody } from '../schemas.ts'; + +export const getSiteSettings = async (c: Context) => c.json(await getRepo().siteSettings.get()); + +export const putSiteSettings = async (c: CtxWithJson) => { + const settings = c.req.valid('json'); + await getRepo().siteSettings.save(settings); + return c.json(settings); +}; diff --git a/packages/gateway/src/middleware/auth.ts b/packages/gateway/src/middleware/auth.ts index 9b2e117071..eb3cb9cc8a 100644 --- a/packages/gateway/src/middleware/auth.ts +++ b/packages/gateway/src/middleware/auth.ts @@ -4,7 +4,7 @@ import { getRepo } from '../repo/index.ts'; import type { ApiKey, User } from '../repo/types.ts'; import { getEnvOptional, timingSafeEqual } from '@floway-dev/platform'; -const PUBLIC_PATHS = new Set(['/api/health', '/favicon.ico']); +const PUBLIC_PATHS = new Set(['/api/health', '/api/site-settings', '/favicon.ico']); const AUTH_VALIDATE_PATHS = new Set(['/auth/login']); // The three slots auth middleware stamps on every authenticated request. All diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts index d55c13b370..10fbc9d8ca 100644 --- a/packages/gateway/src/repo/sql.ts +++ b/packages/gateway/src/repo/sql.ts @@ -37,6 +37,8 @@ import type { ResponsesItemsRepo, ResponsesSnapshotsRepo, ScheduledMaintenanceRepo, + SiteSettings, + SiteSettingsRepo, SpilledFilesRepo, WebSearchConfigRepo, WebSearchUsageRecord, @@ -865,6 +867,28 @@ class SqlWebSearchConfigRepo implements WebSearchConfigRepo { } } +class SqlSiteSettingsRepo implements SiteSettingsRepo { + constructor(private db: SqlDatabase) {} + + async get(): Promise { + const row = await this.db + .prepare('SELECT name FROM site_settings WHERE id = 1') + .first(); + if (!row) throw new Error('site_settings singleton row missing'); + return row; + } + + async save(settings: SiteSettings): Promise { + await this.db + .prepare( + `INSERT INTO site_settings (id, name) VALUES (1, ?) + ON CONFLICT (id) DO UPDATE SET name = excluded.name`, + ) + .bind(settings.name) + .run(); + } +} + // Losing once is ordinary on a row this contended, losing four times in a row // is not — and the attempts run back-to-back with no delay, so they observe // much the same contention rather than independent draws. The bound is a @@ -1579,6 +1603,7 @@ export class SqlRepo implements Repo { usage: UsageRepo; webSearchUsage: WebSearchUsageRepo; performance: PerformanceRepo; + siteSettings: SiteSettingsRepo; webSearchConfig: WebSearchConfigRepo; upstreams: UpstreamRepo; proxies: ProxyRepo; @@ -1598,6 +1623,7 @@ export class SqlRepo implements Repo { this.usage = new SqlUsageRepo(db); this.webSearchUsage = new SqlWebSearchUsageRepo(db); this.performance = new SqlPerformanceRepo(db); + this.siteSettings = new SqlSiteSettingsRepo(db); this.webSearchConfig = new SqlWebSearchConfigRepo(db); this.upstreams = new SqlUpstreamRepo(db); this.proxies = new SqlProxyRepo(db); diff --git a/packages/gateway/src/repo/types.ts b/packages/gateway/src/repo/types.ts index c634ace86f..7efa95b838 100644 --- a/packages/gateway/src/repo/types.ts +++ b/packages/gateway/src/repo/types.ts @@ -340,6 +340,15 @@ export interface WebSearchConfigRepo { save(config: WebSearchConfig): Promise; } +export interface SiteSettings { + name: string; +} + +export interface SiteSettingsRepo { + get(): Promise; + save(settings: SiteSettings): Promise; +} + export interface UpstreamRepo { list(): Promise; getById(id: string): Promise; @@ -548,6 +557,7 @@ export interface Repo { usage: UsageRepo; webSearchUsage: WebSearchUsageRepo; performance: PerformanceRepo; + siteSettings: SiteSettingsRepo; webSearchConfig: WebSearchConfigRepo; upstreams: UpstreamRepo; proxies: ProxyRepo;