From 111d0d9fef5c9d7c41a10470db70d40dfd7abeff Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Fri, 19 Jun 2026 20:03:42 +0000 Subject: [PATCH] Extract control center state validation seam --- apps/desktop/scripts/run-tests.mjs | 1 + apps/desktop/src/check-packaging-contract.ts | 11 + apps/desktop/src/codemap.md | 6 +- .../src/control-center-core-services.ts | 4 +- .../src/control-center-state-validation.ts | 199 ++++++++++++++++++ apps/desktop/src/control-center-state.ts | 194 +---------------- apps/desktop/src/openapi-chat-model.ts | 6 + apps/desktop/src/openapi-chat-settings.ts | 9 +- .../control-center-service-barrels.test.ts | 3 + .../control-center-state-validation.test.ts | 25 +++ .../tests/openapi-chat-settings.test.ts | 5 +- 11 files changed, 263 insertions(+), 200 deletions(-) create mode 100644 apps/desktop/src/control-center-state-validation.ts create mode 100644 apps/desktop/src/openapi-chat-model.ts create mode 100644 apps/desktop/tests/control-center-state-validation.test.ts diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index 063d7867..d3315c9b 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -39,6 +39,7 @@ const behaviorTests = [ ".test-dist/tests/settings-view-state.test.js", ".test-dist/tests/settings-view-action-seams.test.js", ".test-dist/tests/settings-view-state-actions-split.test.js", + ".test-dist/tests/control-center-state-validation.test.js", ".test-dist/tests/settings-openapi-group-provider.test.js", ".test-dist/tests/settings-tts-group-split.test.js", ".test-dist/tests/default-familiar-external-show.test.js", diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 4ce949d1..1fca3e06 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -86,6 +86,9 @@ const controlCenterPreloadSource = readFileSync(join(appDir, "control-center-pre const controlCenterRendererSource = readFileSync(join(appDir, "src", "renderer", "src", "main.tsx"), "utf8"); const controlCenterRouteLoadersSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "route-loaders.tsx"), "utf8"); const controlCenterRouteDataLoadersSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "route-data-loaders.ts"), "utf8"); +const controlCenterCoreServicesSource = readFileSync(join(appDir, "src", "control-center-core-services.ts"), "utf8"); +const controlCenterStateSource = readFileSync(join(appDir, "src", "control-center-state.ts"), "utf8"); +const controlCenterStateValidationSource = readFileSync(join(appDir, "src", "control-center-state-validation.ts"), "utf8"); const controlCenterSharedSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "shared.tsx"), "utf8"); const controlCenterSharedUiSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "shared-ui.tsx"), "utf8"); const controlCenterSharedUiIconsSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "shared-ui-icons.tsx"), "utf8"); @@ -140,6 +143,7 @@ const openApiChatSource = readFileSync(join(appDir, "src", "openapi-chat.ts"), " const openApiChatPromptFlowsSource = readFileSync(join(appDir, "src", "openapi-chat-prompt-flows.ts"), "utf8"); const openApiChatPromptPlainFlowSource = readFileSync(join(appDir, "src", "openapi-chat-prompt-plain-flow.ts"), "utf8"); const openApiChatSettingsSource = readFileSync(join(appDir, "src", "openapi-chat-settings.ts"), "utf8"); +const openApiChatModelSource = readFileSync(join(appDir, "src", "openapi-chat-model.ts"), "utf8"); const openApiChatPresentationSource = readFileSync(join(appDir, "src", "openapi-chat-presentation.ts"), "utf8"); const openApiChatToolLoopSource = readFileSync(join(appDir, "src", "openapi-chat-tool-loop.ts"), "utf8"); const openApiChatConfigStoreSource = readFileSync(join(appDir, "src", "openapi-chat-config-store.ts"), "utf8"); @@ -431,6 +435,10 @@ assert.match(controlCenterInternalUiSource, /familiaros:get-reaction-animation-s assert.match(controlCenterInternalUiSource, /reactionAnimationOverrides/, "settings window must be able to persist reaction animation overrides."); assert.match(controlCenterInternalUiSource, /familiaros-familiar-preview/, "settings reaction preview must use a scoped internal familiar preview protocol."); assert.match(controlCenterInternalUiSource, /familiaros:open-update-release-page/, "settings window must be able to open the release page."); +assert.match(controlCenterCoreServicesSource, /from "\.\/control-center-state-validation(?:\.js)?"/, "Control Center core services barrel must export the extracted state validation seam."); +assert.match(controlCenterStateValidationSource, /export function validatePreferencePatch/, "Control Center state validation seam must export preferences validation."); +assert.match(controlCenterStateValidationSource, /export function validateExternalUrl/, "Control Center state validation seam must export external URL validation."); +assert.doesNotMatch(controlCenterStateSource, /export function validatePreferencePatch/, "Control Center state snapshot surface must no longer own preferences validation directly."); assert.match(controlCenterPreloadSource, /checkForUpdates/, "Control Center preload must expose update checks."); assert.match(controlCenterPreloadSource, /getReactionAnimationSettings/, "Control Center preload must expose reaction animation settings metadata."); assert.match(controlCenterPreloadSource, /saveOpenApiCredential/, "Control Center preload must expose OpenAPI chat credential management."); @@ -656,6 +664,9 @@ assert.match(openApiChatPromptPlainFlowSource, /from "\.\/openapi-chat-request-h assert.match(openApiChatSettingsSource, /from "\.\/openapi-chat-config-store(?:\.js)?"/, "OpenAPI chat settings seam must import the extracted config-store seam."); assert.match(openApiChatSettingsSource, /export function getOpenApiChatSettingsSnapshot/, "OpenAPI chat settings seam must export the settings snapshot helper."); assert.match(openApiChatSettingsSource, /export function storeOpenApiCredential/, "OpenAPI chat settings seam must export credential persistence."); +assert.match(openApiChatSettingsSource, /from "\.\/openapi-chat-model(?:\.js)?"/, "OpenAPI chat settings seam must import the extracted model normalization helper."); +assert.match(openApiChatSettingsSource, /export \{ normalizeChatModel \} from "\.\/openapi-chat-model(?:\.js)?"/, "OpenAPI chat settings seam must re-export model normalization."); +assert.match(openApiChatModelSource, /export function normalizeChatModel/, "OpenAPI chat model seam must export model normalization."); assert.match(openApiChatPresentationSource, /export function handleOpenApiFailure/, "OpenAPI chat presentation seam must export failure presentation."); assert.match(openApiChatPresentationSource, /export function speakAssistantResponse/, "OpenAPI chat presentation seam must export assistant TTS presentation."); assert.match(openApiChatConfigStoreSource, /export function readStoredOpenApiChatConfig/, "OpenAPI chat config-store seam must export config reads."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 64fa87c2..cecbb922 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -198,6 +198,9 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `tray.ts`: Tray icon (nativeImage), context menu builder, update status integration, route-targeted Control Center entries, logs folder - `windows.ts`: Control Center BrowserWindow factory, Dashboard snapshot, IPC handler registration, route targeting, reaction animation settings, plugin/integration/familiar/settings UI IPC endpoints, and scoped internal protocols - `control-center-ipc.ts`: Control Center IPC installer that composes extracted route registration seams for settings, knowledge, plugins, pets, and agent setup +- `control-center-core-services.ts`: Control Center service barrel for state snapshots, validation helpers, update helpers, and locale wiring +- `control-center-state.ts`: Control Center state snapshot and dashboard/preview loaders +- `control-center-state-validation.ts`: Extracted Control Center preferences patch, plain-object, and external-URL validation helpers - `control-center-ipc-shared.ts`: Shared Control Center IPC sender validation and window-kind helpers - `control-center-ipc-settings.ts`: Settings/update/TTS/openapi credential/clipboard/external-link IPC routes for the Control Center - `control-center-ipc-knowledge.ts`: FamiliarOS memory and knowledge-store IPC routes for the Control Center @@ -220,7 +223,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `openapi-chat.ts`: Public OpenAPI chat facade, conversation/runtime state holder, and prompt-window entrypoint - `openapi-chat-prompt-flows.ts`: Public prompt-flow barrel plus MCP-tool chat orchestration over the extracted plain-flow seam - `openapi-chat-prompt-plain-flow.ts`: Extracted plain prompt submission flow, instruction-context assembly, and provider fallback handling -- `openapi-chat-settings.ts`: Extracted OpenAPI chat credential persistence, settings snapshot, model/endpoint normalization, and required-credential helpers +- `openapi-chat-settings.ts`: Extracted OpenAPI chat credential persistence, settings snapshot, endpoint normalization, and required-credential helpers +- `openapi-chat-model.ts`: Extracted pure OpenAPI chat model-name normalization helper - `openapi-chat-presentation.ts`: Extracted familiar/TTS pending, success, and failure presentation helpers for prompt-window chat - `familiaros-memory.ts`: Public long-term memory API, CRUD orchestration, context shaping, and prompt-memory capture for chat, IPC, and knowledge-store flows - `familiaros-memory-store.ts`: Extracted memory cache/persistence, JSON and markdown mirror writes, normalization, retention pruning, and mutation helpers diff --git a/apps/desktop/src/control-center-core-services.ts b/apps/desktop/src/control-center-core-services.ts index 5fb01fcd..17a52af9 100644 --- a/apps/desktop/src/control-center-core-services.ts +++ b/apps/desktop/src/control-center-core-services.ts @@ -6,10 +6,12 @@ export { getPetsStateSnapshot, getReactionAnimationSettingsSnapshot, getSettingsStateSnapshot, +} from "./control-center-state.js"; +export { isPlainObject, validateExternalUrl, validatePreferencePatch, -} from "./control-center-state.js"; +} from "./control-center-state-validation.js"; export { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js"; export { getActiveLocale, setLocaleFromPreference } from "./i18n/index.js"; export { debug, error as logError, warn } from "./logger.js"; diff --git a/apps/desktop/src/control-center-state-validation.ts b/apps/desktop/src/control-center-state-validation.ts new file mode 100644 index 00000000..dd959a7f --- /dev/null +++ b/apps/desktop/src/control-center-state-validation.ts @@ -0,0 +1,199 @@ +import { + normalizeFamiliarName, + normalizePetScale, +} from "./app-state-core.js"; +import { + normalizeOpenApiChatEndpoint, + type FamiliarOSPreferences, +} from "./app-state-preferences.js"; +import { + isSupportedLocale, +} from "./i18n/index.js"; +import { normalizeChatModel } from "./openapi-chat-model.js"; +import { validateReactionAnimationOverrides } from "./reaction-animation-mapping.js"; +import { listTtsProviders } from "./tts-engine.js"; +import { type TtsProviderId } from "./tts-service.js"; + +type Mutable = { -readonly [K in keyof T]: T[K] }; + +export type ControlCenterPreferencesPatch = Partial>; + +export function validatePreferencePatch(value: unknown): ControlCenterPreferencesPatch { + if (!isRecord(value)) { + throw new Error("Invalid preferences patch."); + } + + const patch: ControlCenterPreferencesPatch = {}; + + if ("openDefaultPetOnLaunch" in value) { + if (typeof value.openDefaultPetOnLaunch !== "boolean") throw new Error("Invalid open-on-launch value."); + patch.openDefaultPetOnLaunch = value.openDefaultPetOnLaunch; + } + + if ("locale" in value) { + if (value.locale !== "system" && !isSupportedLocale(value.locale)) throw new Error("Invalid locale value."); + patch.locale = value.locale; + } + + if ("petScale" in value) { + const scale = normalizePetScale(value.petScale); + if (scale !== value.petScale) throw new Error("Invalid familiar scale value."); + patch.petScale = scale; + } + + if ("reactionAnimationOverrides" in value) { + patch.reactionAnimationOverrides = validateReactionAnimationOverrides(value.reactionAnimationOverrides); + } + + if ("openApiChatModel" in value) { + if (value.openApiChatModel === undefined || value.openApiChatModel === null || value.openApiChatModel === "") { + patch.openApiChatModel = undefined; + } else { + const model = normalizeChatModel(value.openApiChatModel); + if (!model) throw new Error("Invalid model name."); + patch.openApiChatModel = model; + } + } + + if ("openApiChatSystemPrompt" in value) { + if (value.openApiChatSystemPrompt === undefined || value.openApiChatSystemPrompt === null || value.openApiChatSystemPrompt === "") { + patch.openApiChatSystemPrompt = undefined; + } else if (typeof value.openApiChatSystemPrompt === "string") { + patch.openApiChatSystemPrompt = value.openApiChatSystemPrompt; + } else { + throw new Error("Invalid familiar character prompt."); + } + } + + if ("openApiChatEndpoint" in value) { + if (value.openApiChatEndpoint === undefined || value.openApiChatEndpoint === null || value.openApiChatEndpoint === "") { + patch.openApiChatEndpoint = undefined; + } else { + const endpoint = normalizeOpenApiChatEndpoint(value.openApiChatEndpoint); + if (!endpoint) { + throw new Error("Invalid OpenAPI-compatible endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full /responses or /chat/completions URL."); + } + patch.openApiChatEndpoint = endpoint; + } + } + + if ("openApiChatTheme" in value) { + if (value.openApiChatTheme !== "system" && value.openApiChatTheme !== "light" && value.openApiChatTheme !== "dark") { + throw new Error("Invalid theme mode."); + } + patch.openApiChatTheme = value.openApiChatTheme; + } + + if ("openApiChatBaseInstructionsEnabled" in value) { + if (typeof value.openApiChatBaseInstructionsEnabled !== "boolean") { + throw new Error("Invalid base instructions value."); + } + patch.openApiChatBaseInstructionsEnabled = value.openApiChatBaseInstructionsEnabled; + } + + if ("ttsProvider" in value) { + const validIds = listTtsProviders().map((provider) => provider.id); + if (!validIds.includes(value.ttsProvider as TtsProviderId)) { + throw new Error("Invalid TTS provider."); + } + patch.ttsProvider = value.ttsProvider as TtsProviderId; + } + + if ("ttsVoice" in value) { + if (value.ttsVoice === undefined || value.ttsVoice === null || value.ttsVoice === "") { + patch.ttsVoice = undefined; + } else if (typeof value.ttsVoice === "string") { + patch.ttsVoice = value.ttsVoice; + } else { + throw new Error("Invalid TTS voice."); + } + } + + if ("ttsSpeed" in value) { + const speed = Number(value.ttsSpeed); + if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) { + throw new Error("Invalid TTS speed. Must be between 0.5 and 2."); + } + patch.ttsSpeed = Math.round(speed * 10) / 10; + } + + if ("ttsModel" in value) { + if (value.ttsModel === undefined || value.ttsModel === null || value.ttsModel === "") { + patch.ttsModel = undefined; + } else if (typeof value.ttsModel === "string") { + const trimmed = value.ttsModel.trim(); + if (trimmed.length > 120) throw new Error("Invalid TTS model name."); + patch.ttsModel = trimmed; + } else { + throw new Error("Invalid TTS model."); + } + } + + if ("ttsEndpointPreset" in value) { + if (value.ttsEndpointPreset !== "openrouter" && value.ttsEndpointPreset !== "litellm" && value.ttsEndpointPreset !== "wavespeedai" && value.ttsEndpointPreset !== "custom") { + throw new Error("Invalid TTS endpoint preset."); + } + patch.ttsEndpointPreset = value.ttsEndpointPreset; + } + + if ("ttsEndpoint" in value) { + if (value.ttsEndpoint === undefined || value.ttsEndpoint === null || value.ttsEndpoint === "") { + patch.ttsEndpoint = undefined; + } else { + const endpoint = normalizeOpenApiChatEndpoint(value.ttsEndpoint); + if (!endpoint) { + throw new Error("Invalid TTS endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full URL."); + } + patch.ttsEndpoint = endpoint; + } + } + + if ("familiarName" in value) { + if (value.familiarName === undefined || value.familiarName === null || value.familiarName === "") { + patch.familiarName = undefined; + } else if (typeof value.familiarName === "string") { + const name = normalizeFamiliarName(value.familiarName); + if (name === undefined) { + throw new Error("Invalid Familiar name. Use up to 64 characters and avoid control characters."); + } + patch.familiarName = name; + } else { + throw new Error("Invalid Familiar name."); + } + } + + return patch; +} + +export function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +export function validateExternalUrl(value: unknown): string { + if (typeof value !== "string") { + throw new Error("Invalid URL."); + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error("Invalid URL."); + } + + const isLocalHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname); + if ((parsed.protocol !== "https:" && !isLocalHttp) + || parsed.username + || parsed.password + || parsed.hash) { + throw new Error("Only https URLs, or localhost http URLs, are allowed."); + } + + return parsed.toString(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/desktop/src/control-center-state.ts b/apps/desktop/src/control-center-state.ts index 7e7739fe..570385d1 100644 --- a/apps/desktop/src/control-center-state.ts +++ b/apps/desktop/src/control-center-state.ts @@ -3,16 +3,14 @@ import { join } from "node:path"; import { app } from "electron"; -import { getAppStateSnapshot, normalizeFamiliarName, normalizeOpenApiChatEndpoint, normalizePetScale, petScaleOptions } from "./app-state.js"; +import { getAppStateSnapshot, petScaleOptions } from "./app-state.js"; import { getCatalogUiState } from "./catalog.js"; import { getInstalledPetDir } from "./familiar-paths.js"; -import { getActiveLocale, getActiveMessages, isSupportedLocale, LOCALE_LABELS, SUPPORTED_LOCALES, t, type Locale, type LocalePreference } from "./i18n/index.js"; +import { getActiveLocale, getActiveMessages, LOCALE_LABELS, SUPPORTED_LOCALES, t, type Locale, type LocalePreference } from "./i18n/index.js"; import { warn } from "./logger.js"; -import { getOpenApiChatSettingsSnapshot, normalizeChatModel } from "./openapi-chat.js"; +import { getOpenApiChatSettingsSnapshot } from "./openapi-chat.js"; import { getPluginService } from "./plugin-service.js"; -import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata, validateReactionAnimationOverrides } from "./reaction-animation-mapping.js"; -import { listTtsProviders } from "./tts-engine.js"; -import { type TtsProviderId } from "./tts-service.js"; +import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata } from "./reaction-animation-mapping.js"; import { getUpdateStatus } from "./update-checker.js"; type ControlCenterSettingsPreferences = Pick< @@ -36,10 +34,6 @@ type ControlCenterSettingsPreferences = Pick< | "familiarName" >; -type Mutable = { -readonly [K in keyof T]: T[K] }; - -export type ControlCenterPreferencesPatch = Partial["preferences"]>>; - export function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; familiars: ReturnType["familiars"] } { const state = getAppStateSnapshot(); return { preferences: { defaultPetId: state.preferences.defaultPetId }, familiars: state.familiars }; @@ -173,191 +167,11 @@ export async function getDefaultPetPreviewSpriteInfo(): Promise<{ readonly path: return { path: builtInPath, version: `builtin-${Math.round(fallback.mtimeMs)}-${fallback.size}` }; } -export function validatePreferencePatch(value: unknown): ControlCenterPreferencesPatch { - if (!isRecord(value)) { - throw new Error("Invalid preferences patch."); - } - - const patch: ControlCenterPreferencesPatch = {}; - - if ("openDefaultPetOnLaunch" in value) { - if (typeof value.openDefaultPetOnLaunch !== "boolean") throw new Error("Invalid open-on-launch value."); - patch.openDefaultPetOnLaunch = value.openDefaultPetOnLaunch; - } - - if ("locale" in value) { - if (value.locale !== "system" && !isSupportedLocale(value.locale)) throw new Error("Invalid locale value."); - patch.locale = value.locale; - } - - if ("petScale" in value) { - const scale = normalizePetScale(value.petScale); - if (scale !== value.petScale) throw new Error("Invalid familiar scale value."); - patch.petScale = scale; - } - - if ("reactionAnimationOverrides" in value) { - patch.reactionAnimationOverrides = validateReactionAnimationOverrides(value.reactionAnimationOverrides); - } - - if ("openApiChatModel" in value) { - if (value.openApiChatModel === undefined || value.openApiChatModel === null || value.openApiChatModel === "") { - patch.openApiChatModel = undefined; - } else { - const model = normalizeChatModel(value.openApiChatModel); - if (!model) throw new Error("Invalid model name."); - patch.openApiChatModel = model; - } - } - - if ("openApiChatSystemPrompt" in value) { - if (value.openApiChatSystemPrompt === undefined || value.openApiChatSystemPrompt === null || value.openApiChatSystemPrompt === "") { - patch.openApiChatSystemPrompt = undefined; - } else if (typeof value.openApiChatSystemPrompt === "string") { - patch.openApiChatSystemPrompt = value.openApiChatSystemPrompt; - } else { - throw new Error("Invalid familiar character prompt."); - } - } - - if ("openApiChatEndpoint" in value) { - if (value.openApiChatEndpoint === undefined || value.openApiChatEndpoint === null || value.openApiChatEndpoint === "") { - patch.openApiChatEndpoint = undefined; - } else { - const endpoint = normalizeOpenApiChatEndpoint(value.openApiChatEndpoint); - if (!endpoint) { - throw new Error("Invalid OpenAPI-compatible endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full /responses or /chat/completions URL."); - } - patch.openApiChatEndpoint = endpoint; - } - } - - if ("openApiChatTheme" in value) { - if (value.openApiChatTheme !== "system" && value.openApiChatTheme !== "light" && value.openApiChatTheme !== "dark") { - throw new Error("Invalid theme mode."); - } - patch.openApiChatTheme = value.openApiChatTheme; - } - - if ("openApiChatBaseInstructionsEnabled" in value) { - if (typeof value.openApiChatBaseInstructionsEnabled !== "boolean") { - throw new Error("Invalid base instructions value."); - } - patch.openApiChatBaseInstructionsEnabled = value.openApiChatBaseInstructionsEnabled; - } - - if ("ttsProvider" in value) { - const validIds = listTtsProviders().map((provider) => provider.id); - if (!validIds.includes(value.ttsProvider as TtsProviderId)) { - throw new Error("Invalid TTS provider."); - } - patch.ttsProvider = value.ttsProvider as TtsProviderId; - } - - if ("ttsVoice" in value) { - if (value.ttsVoice === undefined || value.ttsVoice === null || value.ttsVoice === "") { - patch.ttsVoice = undefined; - } else if (typeof value.ttsVoice === "string") { - patch.ttsVoice = value.ttsVoice; - } else { - throw new Error("Invalid TTS voice."); - } - } - - if ("ttsSpeed" in value) { - const speed = Number(value.ttsSpeed); - if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) { - throw new Error("Invalid TTS speed. Must be between 0.5 and 2."); - } - patch.ttsSpeed = Math.round(speed * 10) / 10; - } - - if ("ttsModel" in value) { - if (value.ttsModel === undefined || value.ttsModel === null || value.ttsModel === "") { - patch.ttsModel = undefined; - } else if (typeof value.ttsModel === "string") { - const trimmed = value.ttsModel.trim(); - if (trimmed.length > 120) throw new Error("Invalid TTS model name."); - patch.ttsModel = trimmed; - } else { - throw new Error("Invalid TTS model."); - } - } - - if ("ttsEndpointPreset" in value) { - if (value.ttsEndpointPreset !== "openrouter" && value.ttsEndpointPreset !== "litellm" && value.ttsEndpointPreset !== "wavespeedai" && value.ttsEndpointPreset !== "custom") { - throw new Error("Invalid TTS endpoint preset."); - } - patch.ttsEndpointPreset = value.ttsEndpointPreset; - } - - if ("ttsEndpoint" in value) { - if (value.ttsEndpoint === undefined || value.ttsEndpoint === null || value.ttsEndpoint === "") { - patch.ttsEndpoint = undefined; - } else { - const endpoint = normalizeOpenApiChatEndpoint(value.ttsEndpoint); - if (!endpoint) { - throw new Error("Invalid TTS endpoint. Use https, or http only for localhost, and provide a base ending in /v1 or a full URL."); - } - patch.ttsEndpoint = endpoint; - } - } - - if ("familiarName" in value) { - if (value.familiarName === undefined || value.familiarName === null || value.familiarName === "") { - patch.familiarName = undefined; - } else if (typeof value.familiarName === "string") { - const name = normalizeFamiliarName(value.familiarName); - if (name === undefined) { - throw new Error("Invalid Familiar name. Use up to 64 characters and avoid control characters."); - } - patch.familiarName = name; - } else { - throw new Error("Invalid Familiar name."); - } - } - - return patch; -} - export function getLaunchAtLoginState(): { supported: boolean; enabled: boolean } { if (!isLaunchAtLoginSupported()) return { supported: false, enabled: false }; return { supported: true, enabled: app.getLoginItemSettings().openAtLogin }; } -export function isPlainObject(value: unknown): value is Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value) as unknown; - return prototype === Object.prototype || prototype === null; -} - -export function validateExternalUrl(value: unknown): string { - if (typeof value !== "string") { - throw new Error("Invalid URL."); - } - - let parsed: URL; - try { - parsed = new URL(value); - } catch { - throw new Error("Invalid URL."); - } - - const isLocalHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname); - if ((parsed.protocol !== "https:" && !isLocalHttp) - || parsed.username - || parsed.password - || parsed.hash) { - throw new Error("Only https URLs, or localhost http URLs, are allowed."); - } - - return parsed.toString(); -} - function isLaunchAtLoginSupported(): boolean { return process.platform === "darwin" || process.platform === "win32"; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/apps/desktop/src/openapi-chat-model.ts b/apps/desktop/src/openapi-chat-model.ts new file mode 100644 index 00000000..640aeb24 --- /dev/null +++ b/apps/desktop/src/openapi-chat-model.ts @@ -0,0 +1,6 @@ +export function normalizeChatModel(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined; + return trimmed; +} diff --git a/apps/desktop/src/openapi-chat-settings.ts b/apps/desktop/src/openapi-chat-settings.ts index a12ba334..525cd499 100644 --- a/apps/desktop/src/openapi-chat-settings.ts +++ b/apps/desktop/src/openapi-chat-settings.ts @@ -2,6 +2,7 @@ import { app, safeStorage } from "electron"; import { defaultOpenApiChatEndpoint, getAppStateSnapshot, normalizeOpenApiChatEndpoint } from "./app-state.js"; import { info } from "./logger.js"; +import { normalizeChatModel } from "./openapi-chat-model.js"; import { getPreferredOpenApiChatStorageMode, getStoredOpenApiCredential, @@ -82,13 +83,7 @@ export function getConfiguredOpenApiChatModel(): string { export function getConfiguredOpenApiChatEndpoint(): string { return normalizeOpenApiChatEndpoint(getAppStateSnapshot().preferences.openApiChatEndpoint) ?? defaultOpenApiChatEndpoint; } - -export function normalizeChatModel(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined; - return trimmed; -} +export { normalizeChatModel } from "./openapi-chat-model.js"; export function getRequiredCredential(): string { const credential = getStoredOpenApiCredential(readStoredChatConfig(), safeStorage); diff --git a/apps/desktop/tests/control-center-service-barrels.test.ts b/apps/desktop/tests/control-center-service-barrels.test.ts index 839275a7..67e6501e 100644 --- a/apps/desktop/tests/control-center-service-barrels.test.ts +++ b/apps/desktop/tests/control-center-service-barrels.test.ts @@ -36,6 +36,9 @@ assert.match(toolkitInstallerSource, /from "\.\/mcp-toolkit-installer-support(?: assert.match(toolkitInstallerSource, /from "\.\/mcp-toolkit-installer-commands(?:\.js)?"/, "MCP toolkit installer must import the extracted command seam."); assert.match(coreBarrelSource, /getAppStateSnapshot/); assert.match(coreBarrelSource, /getSettingsStateSnapshot/); +assert.match(coreBarrelSource, /from "\.\/control-center-state-validation\.js"/); +assert.match(coreBarrelSource, /validatePreferencePatch/); +assert.match(coreBarrelSource, /validateExternalUrl/); assert.match(coreBarrelSource, /openUpdateReleasePage/); assert.match(dataBarrelSource, /getPluginService/); assert.match(dataBarrelSource, /getOpenApiChatSettingsSnapshot/); diff --git a/apps/desktop/tests/control-center-state-validation.test.ts b/apps/desktop/tests/control-center-state-validation.test.ts new file mode 100644 index 00000000..df7b0ddd --- /dev/null +++ b/apps/desktop/tests/control-center-state-validation.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const controlCenterStateSource = readFileSync(resolve(desktopRoot, "src/control-center-state.ts"), "utf8"); +const controlCenterStateValidationSource = readFileSync(resolve(desktopRoot, "src/control-center-state-validation.ts"), "utf8"); +const controlCenterCoreServicesSource = readFileSync(resolve(desktopRoot, "src/control-center-core-services.ts"), "utf8"); + +assert.match(controlCenterStateValidationSource, /export function validatePreferencePatch/, "Control Center state validation seam must export preferences validation."); +assert.match(controlCenterStateValidationSource, /export function validateExternalUrl/, "Control Center state validation seam must export external URL validation."); +assert.match(controlCenterStateValidationSource, /export function isPlainObject/, "Control Center state validation seam must export plain-object validation."); +assert.match(controlCenterCoreServicesSource, /from "\.\/control-center-state-validation\.js"/, "Control Center core services barrel must export the extracted state validation seam."); +assert.doesNotMatch(controlCenterStateSource, /export function validatePreferencePatch/, "Control Center state snapshot surface should no longer own preference validation directly."); +assert.match(controlCenterStateValidationSource, /from "\.\/app-state-core(?:\.js)?"/, "Control Center state validation seam must import the extracted pure app-state core helpers."); +assert.match(controlCenterStateValidationSource, /from "\.\/app-state-preferences(?:\.js)?"/, "Control Center state validation seam must import the extracted pure app-state preference helpers."); +assert.match(controlCenterStateValidationSource, /from "\.\/openapi-chat-model(?:\.js)?"/, "Control Center state validation seam must import the extracted pure OpenAPI model helper."); +assert.match(controlCenterStateValidationSource, /Math\.round\(speed \* 10\) \/ 10/, "Control Center state validation seam must round TTS speed to a single decimal place."); +assert.match(controlCenterStateValidationSource, /Only https URLs, or localhost http URLs, are allowed\./, "Control Center state validation seam must retain the external URL allowlist."); +assert.match(controlCenterStateValidationSource, /listTtsProviders\(\)\.map\(\(provider\) => provider\.id\)/, "Control Center state validation seam must validate TTS providers against the provider catalog."); +assert.match(controlCenterStateValidationSource, /normalizeFamiliarName\(value\.familiarName\)/, "Control Center state validation seam must normalize familiar names through the shared helper."); +assert.match(controlCenterStateValidationSource, /normalizeOpenApiChatEndpoint\(value\.openApiChatEndpoint\)/, "Control Center state validation seam must normalize OpenAPI endpoints through the shared helper."); + +console.error("Control Center state validation seam passed."); diff --git a/apps/desktop/tests/openapi-chat-settings.test.ts b/apps/desktop/tests/openapi-chat-settings.test.ts index 9129fe82..9613593e 100644 --- a/apps/desktop/tests/openapi-chat-settings.test.ts +++ b/apps/desktop/tests/openapi-chat-settings.test.ts @@ -7,6 +7,7 @@ const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileU const openApiChatSource = readFileSync(resolve(desktopRoot, "src/openapi-chat.ts"), "utf8"); const openApiChatPromptFlowsSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-prompt-flows.ts"), "utf8"); const openApiChatSettingsSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-settings.ts"), "utf8"); +const openApiChatModelSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-model.ts"), "utf8"); const openApiChatPresentationSource = readFileSync(resolve(desktopRoot, "src/openapi-chat-presentation.ts"), "utf8"); assert.match(openApiChatSource, /from "\.\/openapi-chat-settings(?:\.js)?"/, "openapi-chat must import the extracted settings seam."); @@ -16,7 +17,9 @@ assert.match(openApiChatSettingsSource, /export function getOpenApiChatSettingsS assert.match(openApiChatSettingsSource, /export function getOpenApiChatSystemPrompt/, "openapi-chat settings seam must export the system-prompt helper."); assert.match(openApiChatSettingsSource, /export function storeOpenApiCredential/, "openapi-chat settings seam must export credential persistence."); assert.match(openApiChatSettingsSource, /export function clearStoredOpenApiCredential/, "openapi-chat settings seam must export credential clearing."); -assert.match(openApiChatSettingsSource, /export function normalizeChatModel/, "openapi-chat settings seam must export model normalization."); +assert.match(openApiChatSettingsSource, /from "\.\/openapi-chat-model(?:\.js)?"/, "openapi-chat settings seam must import the extracted model normalization helper."); +assert.match(openApiChatSettingsSource, /export \{ normalizeChatModel \} from "\.\/openapi-chat-model(?:\.js)?"/, "openapi-chat settings seam must re-export model normalization."); +assert.match(openApiChatModelSource, /export function normalizeChatModel/, "openapi-chat model seam must export model normalization."); assert.match(openApiChatPresentationSource, /export function showOpenApiPendingState/, "openapi-chat presentation seam must export the pending-state helper."); assert.match(openApiChatPresentationSource, /export function showOpenApiSuccessState/, "openapi-chat presentation seam must export the success-state helper."); assert.match(openApiChatPresentationSource, /export function handleOpenApiFailure/, "openapi-chat presentation seam must export the failure presenter.");