281 lines
9.5 KiB
TypeScript
Executable File
281 lines
9.5 KiB
TypeScript
Executable File
import type { AppContext } from "../context.ts";
|
|
import { errorResponse, type Router } from "../../../packages/core/src/mod.ts";
|
|
import {
|
|
jsonResponse,
|
|
parseJsonBody,
|
|
validationErrorResponse,
|
|
} from "./helpers.ts";
|
|
import { SettingsStore } from "./settings_store.ts";
|
|
// Direct-path import (not via contracts/src/mod.ts): the mod re-export is an
|
|
// orchestrator-applied wiring line, so this route must compile without it.
|
|
import {
|
|
type CachingSettings,
|
|
CachingSettingsSchema,
|
|
CompatibilitySettingsSchema,
|
|
DEFAULT_CACHING,
|
|
DEFAULT_COMPATIBILITY,
|
|
DEFAULT_MCP,
|
|
DEFAULT_PERFORMANCE,
|
|
DEFAULT_SECURITY,
|
|
McpSettingsSchema,
|
|
PerformanceSettingsSchema,
|
|
SecurityOverrideSchema,
|
|
type SecuritySettings,
|
|
type SettingSource,
|
|
type SettingsResponse,
|
|
SettingsUpdateSchema,
|
|
} from "../../../packages/contracts/src/settings.ts";
|
|
|
|
/**
|
|
* ADMIN-LOCKOUT INVARIANT (guardrail): admin surfaces that MUST remain
|
|
* reachable no matter what security settings are persisted. Nothing consumes
|
|
* these paths for enforcement today — security settings are persist + reflect
|
|
* only, and none is wired into adminAuthMiddleware, the /api/* routing, or the
|
|
* origin-guard — so the FROSTY_ADMIN_TOKEN path and every /api/* admin route
|
|
* stay reachable regardless of what an operator persists. Any FUTURE wiring
|
|
* that honors whitelistedRoutes / passwordProtect / disableInferenceAuth MUST
|
|
* union with this set so the control plane can never be gated off. Flagged for
|
|
* the Phase-4 security review.
|
|
*/
|
|
export const ADMIN_ALWAYS_REACHABLE = [
|
|
"/api/",
|
|
"/api/settings",
|
|
"/api/session/",
|
|
] as const;
|
|
|
|
/** Defensive env read: a missing --allow-env must degrade to "unset". */
|
|
function env(name: string): string | undefined {
|
|
try {
|
|
return Deno.env.get(name) ?? undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Environment layer for the security group (FROSTY_ALLOWED_HOSTS). */
|
|
function securityEnv(): Partial<SecuritySettings> {
|
|
const raw = env("FROSTY_ALLOWED_HOSTS");
|
|
if (!raw) return {};
|
|
const origins = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
return origins.length ? { allowedOrigins: origins } : {};
|
|
}
|
|
|
|
/** Environment layer for the caching group (FROSTY_CACHE*). */
|
|
function cachingEnv(): Partial<CachingSettings> {
|
|
const layer: Partial<CachingSettings> = {};
|
|
const mode = env("FROSTY_CACHE");
|
|
if (mode === "exact" || mode === "semantic") layer.enabled = true;
|
|
const ttlMs = Number(env("FROSTY_CACHE_TTL_MS"));
|
|
if (Number.isFinite(ttlMs) && ttlMs > 0) {
|
|
const secs = Math.floor(ttlMs / 1000);
|
|
if (secs >= 1) layer.ttlSeconds = secs;
|
|
}
|
|
const model = env("FROSTY_CACHE_EMBED_MODEL");
|
|
if (model) layer.embeddingModel = model;
|
|
return layer;
|
|
}
|
|
|
|
/**
|
|
* Resolves one group into effective values + per-field provenance by layering
|
|
* default <- env <- override. Only keys present in `defaults` are considered,
|
|
* so an override carrying extra markers (e.g. security.hasPassword) is ignored
|
|
* here and handled by the caller.
|
|
*/
|
|
function viewOf<T extends Record<string, unknown>>(
|
|
defaults: T,
|
|
envLayer: Partial<T>,
|
|
override: Partial<T>,
|
|
): { values: T; sources: Record<string, SettingSource> } {
|
|
const values = { ...defaults };
|
|
const sources: Record<string, SettingSource> = {};
|
|
for (const key of Object.keys(defaults) as (keyof T)[]) {
|
|
if (key in override && override[key] !== undefined) {
|
|
values[key] = override[key] as T[keyof T];
|
|
sources[key as string] = "override";
|
|
} else if (key in envLayer && envLayer[key] !== undefined) {
|
|
values[key] = envLayer[key] as T[keyof T];
|
|
sources[key as string] = "env";
|
|
} else {
|
|
sources[key as string] = "default";
|
|
}
|
|
}
|
|
return { values, sources };
|
|
}
|
|
|
|
/** Per-field enforcement map: false everywhere except live cache tuning. */
|
|
function enforcementMap(ctx: AppContext): Record<string, boolean> {
|
|
const groups: Record<string, string[]> = {
|
|
security: [...Object.keys(DEFAULT_SECURITY), "hasPassword"],
|
|
compatibility: Object.keys(DEFAULT_COMPATIBILITY),
|
|
performance: Object.keys(DEFAULT_PERFORMANCE),
|
|
caching: Object.keys(DEFAULT_CACHING),
|
|
mcp: Object.keys(DEFAULT_MCP),
|
|
};
|
|
const map: Record<string, boolean> = {};
|
|
for (const [group, fields] of Object.entries(groups)) {
|
|
for (const field of fields) map[`${group}.${field}`] = false;
|
|
}
|
|
// Wired live only when a cache is actually running (FROSTY_CACHE set).
|
|
const cacheActive = Boolean(ctx.cache);
|
|
map["caching.ttlSeconds"] = cacheActive;
|
|
map["caching.similarityThreshold"] = cacheActive;
|
|
map["caching.cacheByProvider"] = cacheActive;
|
|
map["caching.cacheByModel"] = cacheActive;
|
|
map["caching.excludeSystemPrompt"] = cacheActive;
|
|
map["caching.conversationHistoryThreshold"] = cacheActive;
|
|
return map;
|
|
}
|
|
|
|
function storeFor(ctx: AppContext): SettingsStore | undefined {
|
|
// Reuses the shared KV handle; opens no new connection.
|
|
return ctx.config ? new SettingsStore(ctx.config.raw()) : undefined;
|
|
}
|
|
|
|
/** Reads + schema-sanitizes a group's persisted override (empty when unset). */
|
|
async function loadOverride(
|
|
store: SettingsStore | undefined,
|
|
group: Parameters<SettingsStore["getOverride"]>[0],
|
|
schema: { safeParse: (v: unknown) => { success: boolean; data?: unknown } },
|
|
): Promise<Record<string, unknown>> {
|
|
if (!store) return {};
|
|
const raw = await store.getOverride(group);
|
|
if (!raw) return {};
|
|
const parsed = schema.safeParse(raw);
|
|
if (!parsed.success) return {};
|
|
const data = parsed.data as Record<string, unknown>;
|
|
const stored = raw as Record<string, unknown>;
|
|
const kept: Record<string, unknown> = {};
|
|
for (const key of Object.keys(data)) {
|
|
if (key in stored) kept[key] = data[key];
|
|
}
|
|
return kept;
|
|
}
|
|
|
|
/** Builds the full GET/PUT response from the persisted overrides + env + defaults. */
|
|
async function buildResponse(ctx: AppContext): Promise<SettingsResponse> {
|
|
const store = storeFor(ctx);
|
|
const securityOv = await loadOverride(
|
|
store,
|
|
"security",
|
|
SecurityOverrideSchema,
|
|
);
|
|
const compatOv = await loadOverride(
|
|
store,
|
|
"compatibility",
|
|
CompatibilitySettingsSchema.partial(),
|
|
);
|
|
const perfOv = await loadOverride(
|
|
store,
|
|
"performance",
|
|
PerformanceSettingsSchema.partial(),
|
|
);
|
|
const cacheOv = await loadOverride(
|
|
store,
|
|
"caching",
|
|
CachingSettingsSchema.partial(),
|
|
);
|
|
const mcpOv = await loadOverride(store, "mcp", McpSettingsSchema.partial());
|
|
|
|
const secBase = viewOf(
|
|
DEFAULT_SECURITY,
|
|
securityEnv(),
|
|
securityOv as Partial<SecuritySettings>,
|
|
);
|
|
const hasPassword = securityOv.hasPassword === true;
|
|
|
|
return {
|
|
settings: {
|
|
security: {
|
|
values: { ...secBase.values, hasPassword },
|
|
sources: {
|
|
...secBase.sources,
|
|
hasPassword: securityOv.hasPassword !== undefined
|
|
? "override"
|
|
: "default",
|
|
},
|
|
},
|
|
compatibility: viewOf(DEFAULT_COMPATIBILITY, {}, compatOv),
|
|
performance: viewOf(DEFAULT_PERFORMANCE, {}, perfOv),
|
|
caching: viewOf(DEFAULT_CACHING, cachingEnv(), cacheOv),
|
|
mcp: viewOf(DEFAULT_MCP, {}, mcpOv),
|
|
},
|
|
enforcement: enforcementMap(ctx),
|
|
};
|
|
}
|
|
|
|
export function registerSettingsRoutes(router: Router, ctx: AppContext): void {
|
|
router.get("/api/settings", async () => {
|
|
return jsonResponse(await buildResponse(ctx));
|
|
});
|
|
|
|
router.put("/api/settings", async (req) => {
|
|
const store = storeFor(ctx);
|
|
if (!store) {
|
|
return errorResponse(400, "No persistent settings store is attached.");
|
|
}
|
|
const raw = await parseJsonBody(req);
|
|
const parsed = SettingsUpdateSchema.safeParse(raw);
|
|
if (!parsed.success) {
|
|
return validationErrorResponse(parsed.error);
|
|
}
|
|
const update = parsed.data;
|
|
const sent = <T extends Record<string, unknown>>(
|
|
group: string,
|
|
values: T,
|
|
): Partial<T> => {
|
|
const rawGroup = (raw as Record<string, unknown> | null)?.[group];
|
|
if (rawGroup === null || typeof rawGroup !== "object") {
|
|
return values;
|
|
}
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of Object.keys(values)) {
|
|
if (key in (rawGroup as Record<string, unknown>)) {
|
|
out[key] = values[key];
|
|
}
|
|
}
|
|
return out as Partial<T>;
|
|
};
|
|
|
|
if (update.security) {
|
|
const { password, ...rest } = sent("security", update.security);
|
|
const override: Record<string, unknown> = { ...rest };
|
|
if (password !== undefined) override.hasPassword = true;
|
|
await store.merge("security", override);
|
|
}
|
|
if (update.compatibility) {
|
|
await store.merge(
|
|
"compatibility",
|
|
sent("compatibility", update.compatibility),
|
|
);
|
|
}
|
|
if (update.performance) {
|
|
await store.merge("performance", sent("performance", update.performance));
|
|
}
|
|
if (update.caching) {
|
|
await store.merge("caching", sent("caching", update.caching));
|
|
}
|
|
if (update.mcp) {
|
|
await store.merge("mcp", sent("mcp", update.mcp));
|
|
}
|
|
|
|
const response = await buildResponse(ctx);
|
|
|
|
// Live enforcement: push the new cache-key and tuning controls into the
|
|
// running cache. Values are schema-validated and cache failures stay off
|
|
// the inference path.
|
|
if (update.caching && ctx.cache) {
|
|
const caching = response.settings.caching.values;
|
|
ctx.cache.configure({
|
|
ttlMs: caching.ttlSeconds * 1000,
|
|
similarityThreshold: caching.similarityThreshold,
|
|
cacheByProvider: caching.cacheByProvider,
|
|
cacheByModel: caching.cacheByModel,
|
|
excludeSystemPrompt: caching.excludeSystemPrompt,
|
|
conversationHistoryThreshold: caching.conversationHistoryThreshold,
|
|
});
|
|
}
|
|
|
|
return jsonResponse(response);
|
|
});
|
|
}
|