From 7276bdad21f6aabe6c4c43b58638b7e6759417c9 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 7 Aug 2026 17:59:51 +0200 Subject: [PATCH] refactor(assistant): resolve availability through the existing endpoint Drops the feature-flags module, the /api/features endpoint and its test in favour of calling api.assistant.getAvailability() from the root layout, the same way the layout already calls api.user.getById(). The gate only needs one server-resolved boolean, and getAvailability already computes exactly that. The separate helper duplicated the enabled / modelId / api-key-required rule, so the two copies could drift. A general feature-flag surface is still worth having, but it belongs at boot time across every optional feature rather than as a per-request database read introduced alongside the assistant. --- apps/nextjs/src/app/[locale]/layout.tsx | 12 +++-- apps/nextjs/src/app/api/features/route.ts | 24 --------- packages/api/package.json | 1 - packages/api/src/features.ts | 36 ------------- packages/api/src/test/features.spec.ts | 64 ----------------------- 5 files changed, 9 insertions(+), 128 deletions(-) delete mode 100644 apps/nextjs/src/app/api/features/route.ts delete mode 100644 packages/api/src/features.ts delete mode 100644 packages/api/src/test/features.spec.ts diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index e7db1f0e3..493ccdc9c 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -14,7 +14,6 @@ import type { DayOfWeek } from "@mantine/dates"; import { NextIntlClientProvider } from "next-intl"; import { api } from "@homarr/api/server"; -import { getFeatureFlagsAsync } from "@homarr/api/features"; import { env } from "@homarr/auth/env"; import { auth } from "@homarr/auth/next"; import { db } from "@homarr/db"; @@ -87,7 +86,14 @@ export default async function Layout(props: { const session = await auth(); const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null; const serverSettings = await getServerSettingsAsync(db); - const features = await getFeatureFlagsAsync(db); + // Resolved on the server so the assistant runtime chunk is never referenced by a page when the + // feature is off. Reuses the same availability check the assistant management page invalidates. + const assistantEnabled = session + ? await api.assistant + .getAvailability() + .then((availability) => availability.enabled) + .catch(() => false) + : false; const colorScheme = await getCurrentColorSchemeAsync(); const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; @@ -126,7 +132,7 @@ export default async function Layout(props: { (innerProps) => , (innerProps) => , (innerProps) => , - (innerProps) => , + (innerProps) => , ]); const { locale } = await props.params; diff --git a/apps/nextjs/src/app/api/features/route.ts b/apps/nextjs/src/app/api/features/route.ts deleted file mode 100644 index 06d33f73b..000000000 --- a/apps/nextjs/src/app/api/features/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getFeatureFlagsAsync } from "@homarr/api/features"; -import { auth } from "@homarr/auth/next"; -import { db } from "@homarr/db"; - -/** - * Reports which optional features are switched on for this instance. - * - * Kept deliberately small: booleans only, no configuration detail, so it stays cheap to call and - * safe to hand to the client. It is the single place to look when deciding whether a feature's - * client code needs to be loaded at all. - * - * Requires a session because whether an instance runs an AI assistant is not something an - * anonymous visitor needs to know. - */ -export async function GET() { - const session = await auth(); - if (!session) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const features = await getFeatureFlagsAsync(db); - - return Response.json({ features }, { headers: { "Cache-Control": "private, max-age=60" } }); -} diff --git a/packages/api/package.json b/packages/api/package.json index 14c833eeb..94fb1a00d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -15,7 +15,6 @@ "./shared": "./src/shared.ts", "./open-api": "./src/open-api.ts", "./assistant": "./src/assistant.ts", - "./features": "./src/features.ts", "./custom-widget/display-data": "./src/router/custom-widget/display-data.ts", "./mcp": "./src/mcp.ts" }, diff --git a/packages/api/src/features.ts b/packages/api/src/features.ts deleted file mode 100644 index 9c100b3b3..000000000 --- a/packages/api/src/features.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { eq } from "@homarr/db"; -import type { Database } from "@homarr/db"; -import { assistantConfigurations } from "@homarr/db/schema"; -import { assistantProviderRequiresApiKey } from "@homarr/definitions"; - -/** - * Instance-level feature switches. These say whether a feature is turned on for the whole Homarr - * instance, never whether the current user may use it - permission checks stay on the individual - * procedures. - * - * The point of collecting them in one place is that the server can decide, before rendering, - * whether a feature's client bundle needs to exist on the page at all. A feature that is off must - * not cost the browser anything. - */ -export interface FeatureFlags { - assistant: boolean; -} - -export const getFeatureFlagsAsync = async (db: Database): Promise => { - const assistantConfiguration = await db.query.assistantConfigurations.findFirst({ - where: eq(assistantConfigurations.id, "default"), - columns: { enabled: true, provider: true, modelId: true, encryptedApiKey: true }, - }); - - const requiresApiKey = assistantConfiguration - ? assistantProviderRequiresApiKey(assistantConfiguration.provider) - : false; - - return { - assistant: Boolean( - assistantConfiguration?.enabled && - assistantConfiguration.modelId && - (!requiresApiKey || assistantConfiguration.encryptedApiKey), - ), - }; -}; diff --git a/packages/api/src/test/features.spec.ts b/packages/api/src/test/features.spec.ts deleted file mode 100644 index df0d4f11f..000000000 --- a/packages/api/src/test/features.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import type { Database } from "@homarr/db"; -import { assistantConfigurations } from "@homarr/db/schema"; -import { createDb } from "@homarr/db/test"; - -import { getFeatureFlagsAsync } from "../features"; - -const seedConfigurationAsync = async (db: Database, values: Partial) => { - await db.insert(assistantConfigurations).values({ id: "default", ...values }); -}; - -describe("getFeatureFlagsAsync", () => { - test("reports the assistant as off when it was never configured", async () => { - const db = createDb() as unknown as Database; - - await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false }); - }); - - test("reports the assistant as off while it is configured but not switched on", async () => { - const db = createDb() as unknown as Database; - await seedConfigurationAsync(db, { - enabled: false, - provider: "openrouter", - modelId: "some/model", - encryptedApiKey: "a.b", - }); - - await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false }); - }); - - test("reports the assistant as off when a required api key is missing", async () => { - const db = createDb() as unknown as Database; - await seedConfigurationAsync(db, { enabled: true, provider: "openrouter", modelId: "some/model" }); - - await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false }); - }); - - test("reports the assistant as off when no model was picked", async () => { - const db = createDb() as unknown as Database; - await seedConfigurationAsync(db, { enabled: true, provider: "openrouter", encryptedApiKey: "a.b" }); - - await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: false }); - }); - - test("reports the assistant as on once it is fully configured", async () => { - const db = createDb() as unknown as Database; - await seedConfigurationAsync(db, { - enabled: true, - provider: "openrouter", - modelId: "some/model", - encryptedApiKey: "a.b", - }); - - await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: true }); - }); - - test("does not require an api key for providers that run without one", async () => { - const db = createDb() as unknown as Database; - await seedConfigurationAsync(db, { enabled: true, provider: "ollama", modelId: "llama3" }); - - await expect(getFeatureFlagsAsync(db)).resolves.toEqual({ assistant: true }); - }); -});