Merge origin/main into chore/scanning-sweep

This commit is contained in:
Marc-André Bombeck committed 2026-09-02 16:42:17 +02:00
commit 0635db898a
13 files changed
+323 -9

No files matched your search

+13
View File
@@ -2,6 +2,19 @@
## [Unreleased]
### Fixed
- **The AI server key reaches Anthropic when the base URL points there.** An
operator who filled the server key with an Anthropic key and
`https://api.anthropic.com/v1` got a failure on every AI call and nothing
saying why: the request was built for OpenAI's API and posted at Anthropic's,
which speaks a different shape. The base URL now picks the client. Anthropic's
host, and any subdomain of it, is called on Anthropic's Messages API and
defaults to a Claude model; every other host keeps the OpenAI-compatible API
it had before, which is also the right one for a local server or a gateway. A
base URL that is not a URL at all behaves as it did. The operator card now
says which providers the field accepts. Found in a fork by @sweidinger.
### Security
- **The published image no longer ships four flagged dependency versions.**
+15
View File
@@ -107,6 +107,21 @@ Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL
Actively developed — new releases roughly weekly, issue reports and PRs welcome. Behaviour and schema can change between versions; migrations are forward-only.
## Thanks
HealthLog is built in the open and people keep making it better. Alongside
bug reports, setup questions and the ideas that turned into features, these
people have sent code that shipped:
<a href="https://github.com/MBombeck/HealthLog/graphs/contributors">
<img src="https://contrib.rocks/image?repo=MBombeck/HealthLog" alt="Contributors to HealthLog" />
</a>
Every merged pull request keeps its author's name on the commit, so the
[contributor graph](https://github.com/MBombeck/HealthLog/graphs/contributors)
is the full list. Release notes name the people whose work is in that release,
including anyone who found the defect without sending the fix.
## License
HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE): free to use, self-host, and modify for noncommercial purposes. Commercial use requires a separate agreement — open an issue or reach out via [healthlog.dev](https://healthlog.dev). Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that license.
+13 -8
View File
@@ -745,7 +745,8 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
{
name: "/insights/workouts/:id detail",
path: `/insights/workouts/${A11Y_WORKOUT_ID}`,
painted: (page) => page.locator('[data-slot="workout-detail-header"]'),
painted: (page) =>
page.locator('[data-slot="workout-detail-header"]:visible'),
},
{
// `coach-page` is the sizing wrapper around a `<Suspense fallback={null}>`,
@@ -754,7 +755,9 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
name: "/coach",
path: "/coach",
painted: (page) =>
page.locator('[data-slot="coach-conversation"][data-variant="page"]'),
page.locator(
'[data-slot="coach-conversation"][data-variant="page"]:visible',
),
},
];
@@ -788,7 +791,7 @@ const RECORD_ROUTES: readonly RouteCase[] = [
{
name: "/labs",
path: "/labs",
painted: (page) => page.locator('[data-slot="lab-list"]'),
painted: (page) => page.locator('[data-slot="lab-list"]:visible'),
},
{
name: "/documents compact panel",
@@ -842,7 +845,7 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [
// status pill later, so the panel's presence is the honest ceiling here.
name: "/settings/integrations",
path: "/settings/integrations",
painted: (page) => page.locator('[data-slot="connections-panel"]'),
painted: (page) => page.locator('[data-slot="connections-panel"]:visible'),
},
{
// The heading comes from `<AdminShell>` and is on screen while every card
@@ -850,12 +853,12 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [
// overview and only exists once its query has answered.
name: "/admin overview",
path: "/admin",
painted: (page) => page.locator('[data-slot="admin-audit-rows"]'),
painted: (page) => page.locator('[data-slot="admin-audit-rows"]:visible'),
},
{
name: "/admin/system-status",
path: "/admin/system-status",
painted: (page) => page.locator('[data-slot="system-status-grid"]'),
painted: (page) => page.locator('[data-slot="system-status-grid"]:visible'),
},
{
name: "/admin/users",
@@ -877,7 +880,7 @@ test.describe("axe-core public surfaces", () => {
// around whatever the segment is streaming, so it is satisfied
// before the login card itself exists. The action block is the
// page's own.
page.locator('[data-slot="login-actions"]'),
page.locator('[data-slot="login-actions"]:visible'),
);
expectNoViolations(theme, "/auth/login", blocking);
});
@@ -937,7 +940,9 @@ test.describe("axe-core authenticated route and state matrix", () => {
await test.step(`${theme} /labs open OCR picker dialog`, async () => {
await page.goto("/labs", { waitUntil: "domcontentloaded" });
await expect(page.locator('[data-slot="lab-list"]')).toBeVisible({
await expect(
page.locator('[data-slot="lab-list"]:visible'),
).toBeVisible({
timeout: 15_000,
});
await openMenu(
+1
View File
@@ -6473,6 +6473,7 @@
"description": "Ein Betreiber-Schlüssel für Konten, die keinen eigenen hinterlegt haben.",
"byokHint": "Der eigene Schlüssel (BYOK, ChatGPT-Login oder lokaler Provider) hat immer Vorrang — der Server-Schlüssel ist die letzte Stufe der Provider-Kette.",
"consentHint": "Anfragen über den Server-Schlüssel erfordern die aktive KI-Einwilligung der Person; ohne Einwilligungsbeleg wird nichts gesendet.",
"baseUrlHint": "Die Basis-URL bestimmt die API: eine Anthropic-URL wie https://api.anthropic.com/v1 wird erkannt, jeder andere Host wird über die OpenAI-kompatible API angesprochen.",
"statusConfigured": "Eingerichtet",
"statusNotConfigured": "Nicht eingerichtet",
"keyLabel": "API-Schlüssel",
+1
View File
@@ -6473,6 +6473,7 @@
"description": "An operator key for accounts that have not configured one of their own.",
"byokHint": "A user's own key (BYOK, ChatGPT login or local provider) always takes precedence — the server key is the last step in the provider chain.",
"consentHint": "Requests over the server key require the user's active AI consent; without a consent receipt nothing is sent.",
"baseUrlHint": "The base URL selects the API: an Anthropic URL such as https://api.anthropic.com/v1 is understood, any other host is called on the OpenAI-compatible API.",
"statusConfigured": "Configured",
"statusNotConfigured": "Not configured",
"keyLabel": "API key",
+1
View File
@@ -6473,6 +6473,7 @@
"description": "Una clave del operador para las cuentas que no tienen una propia.",
"byokHint": "La clave propia del usuario (BYOK, inicio de sesión de ChatGPT o proveedor local) siempre tiene prioridad — la clave del servidor es el último paso de la cadena de proveedores.",
"consentHint": "Las solicitudes con la clave del servidor requieren el consentimiento de IA activo del usuario; sin un recibo de consentimiento no se envía nada.",
"baseUrlHint": "La URL base determina la API: se reconoce una URL de Anthropic como https://api.anthropic.com/v1, cualquier otro host se llama a través de la API compatible con OpenAI.",
"statusConfigured": "Configurada",
"statusNotConfigured": "Sin configurar",
"keyLabel": "Clave de API",
+1
View File
@@ -6473,6 +6473,7 @@
"description": "Une clé de l'opérateur pour les comptes qui n'en ont pas configuré une.",
"byokHint": "La clé personnelle de l'utilisateur (BYOK, connexion ChatGPT ou fournisseur local) a toujours la priorité — la clé du serveur est la dernière étape de la chaîne de fournisseurs.",
"consentHint": "Les requêtes via la clé du serveur nécessitent le consentement IA actif de l'utilisateur ; sans justificatif de consentement, rien n'est envoyé.",
"baseUrlHint": "L'URL de base détermine l'API : une URL Anthropic telle que https://api.anthropic.com/v1 est reconnue, tout autre hôte est appelé via l'API compatible OpenAI.",
"statusConfigured": "Configurée",
"statusNotConfigured": "Non configurée",
"keyLabel": "Clé API",
+1
View File
@@ -6473,6 +6473,7 @@
"description": "Una chiave dell'operatore per gli account che non ne hanno una propria.",
"byokHint": "La chiave personale dell'utente (BYOK, accesso ChatGPT o provider locale) ha sempre la precedenza — la chiave del server è l'ultimo passo della catena dei provider.",
"consentHint": "Le richieste tramite la chiave del server richiedono il consenso IA attivo dell'utente; senza una ricevuta di consenso non viene inviato nulla.",
"baseUrlHint": "L'URL di base determina l'API: un URL Anthropic come https://api.anthropic.com/v1 viene riconosciuto, qualsiasi altro host viene chiamato tramite l'API compatibile con OpenAI.",
"statusConfigured": "Configurata",
"statusNotConfigured": "Non configurata",
"keyLabel": "Chiave API",
+1
View File
@@ -6473,6 +6473,7 @@
"description": "자기 키를 설정하지 않은 계정을 위한 운영자 키예요.",
"byokHint": "사용자 본인 키(BYOK, ChatGPT 로그인, 로컬 제공자)가 항상 우선해요 — 서버 키는 제공자 체인의 마지막 단계예요.",
"consentHint": "서버 키로 나가는 요청에는 사용자의 유효한 AI 동의가 필요해요. 동의 기록이 없으면 아무것도 전송하지 않아요.",
"baseUrlHint": "기본 URL이 API를 결정합니다. https://api.anthropic.com/v1 같은 Anthropic 주소는 인식되며, 그 밖의 호스트는 OpenAI 호환 API로 호출됩니다.",
"statusConfigured": "설정됨",
"statusNotConfigured": "설정 안 됨",
"keyLabel": "API 키",
+1
View File
@@ -6473,6 +6473,7 @@
"description": "Klucz operatora dla kont, które nie mają własnego.",
"byokHint": "Własny klucz użytkownika (BYOK, logowanie ChatGPT lub dostawca lokalny) zawsze ma pierwszeństwo — klucz serwera jest ostatnim ogniwem łańcucha dostawców.",
"consentHint": "Żądania przez klucz serwera wymagają aktywnej zgody użytkownika na AI; bez potwierdzenia zgody nic nie jest wysyłane.",
"baseUrlHint": "Adres bazowy decyduje o API: adres Anthropic taki jak https://api.anthropic.com/v1 jest rozpoznawany, każdy inny host jest wywoływany przez API zgodne z OpenAI.",
"statusConfigured": "Skonfigurowany",
"statusNotConfigured": "Nieskonfigurowany",
"keyLabel": "Klucz API",
@@ -34,6 +34,10 @@ import { PasswordInput } from "./_shared";
* key here never silently forwards anyone's health data.
* - The base URL is locked server-side to HTTPS + a hostname allowlist
* (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it).
* - The base URL also picks the wire: an `anthropic.com` host is called on
* Anthropic's Messages API, every other host on the OpenAI-compatible one
* (`resolveAdminProvider` in `src/lib/ai/provider.ts`). `baseUrlHint` is
* that sentence for the operator.
*/
interface AiServerKeyResponse {
@@ -113,6 +117,7 @@ export function AiServerKeySection() {
<div className="space-y-2 text-sm">
<p>{t("admin.aiServerKey.byokHint")}</p>
<p>{t("admin.aiServerKey.consentHint")}</p>
<p>{t("admin.aiServerKey.baseUrlHint")}</p>
</div>
<div className="space-y-1.5">
@@ -0,0 +1,220 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Client selection for the operator's global AI provider.
*
* The claim this file keeps true: the admin base URL decides which wire the
* operator's key rides. An Anthropic host gets the Anthropic Messages wire,
* everything else keeps the OpenAI chat-completions wire — including an
* OpenAI-compatible endpoint the operator allowlisted, an unset base URL and
* a value that does not parse as a URL. Every case is asserted at the wire:
* the test resolves a provider and runs a completion through a stubbed
* transport, so what is checked is the request that would leave the process.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((v: string) => `decrypted:${v}`),
encrypt: vi.fn((v: string) => `encrypted:${v}`),
}));
vi.mock("@/lib/ai/codex-oauth", () => ({
refreshDeviceTokens: vi.fn(),
encryptCodexCreds: vi.fn(),
decryptCodexCreds: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => undefined),
}));
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
import { resolveProvider } from "../provider";
import { singleUserTurn } from "../types";
import { prisma } from "@/lib/db";
/** A user with nothing configured, so resolution falls through to the admin key. */
function emptyUserRow() {
return {
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: null,
aiCompatKeyEncrypted: null,
aiCompatModel: null,
aiProviderChain: null,
useCentralCodex: false,
codexConnectionStatus: null,
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
} as never;
}
function adminSettings(overrides: Record<string, unknown> = {}) {
return {
id: "singleton",
adminAiKeyEncrypted: "enc-admin-key",
adminAiModel: null,
adminAiBaseUrl: null,
...overrides,
} as never;
}
/**
* One transport stub for both wires: the OpenAI body reads `choices`, the
* Anthropic body reads `content`, and each client ignores the other's field.
*/
function stubTransport() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"ok":true}' } }],
usage: { total_tokens: 1 },
content: [{ type: "text", text: '{"ok":true}' }],
}),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
const probe = () =>
singleUserTurn({ system: "s", user: "u", responseFormat: "json" });
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue(emptyUserRow());
});
describe("the admin base URL picks the client", () => {
it("sends an Anthropic base URL down the Anthropic wire", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(
adminSettings({
adminAiBaseUrl: "https://api.anthropic.com/v1",
adminAiModel: "claude-sonnet-4-6",
}),
);
const provider = await resolveProvider("u-1");
expect(provider.type).toBe("anthropic");
const result = await provider.generateCompletion(probe());
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.anthropic.com/v1/messages");
expect(init.headers["x-api-key"]).toBe("decrypted:enc-admin-key");
expect(JSON.parse(init.body).model).toBe("claude-sonnet-4-6");
expect(result.providerType).toBe("anthropic");
});
it("treats a subdomain of anthropic.com as Anthropic, case-insensitively", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(
adminSettings({
adminAiBaseUrl: "https://API.EU.Anthropic.COM/v1",
adminAiModel: "claude-sonnet-4-6",
}),
);
const provider = await resolveProvider("u-1");
expect(provider.type).toBe("anthropic");
await provider.generateCompletion(probe());
// The base URL rides through verbatim; only the host MATCH folds case.
expect(fetchMock.mock.calls[0][0]).toBe(
"https://API.EU.Anthropic.COM/v1/messages",
);
});
it("keeps the OpenAI wire for the OpenAI host", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(
adminSettings({
adminAiBaseUrl: "https://api.openai.com/v1",
adminAiModel: "gpt-4o-mini",
}),
);
const provider = await resolveProvider("u-1");
expect(provider.type).toBe("admin-key");
await provider.generateCompletion(probe());
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.openai.com/v1/chat/completions");
expect(init.headers.Authorization).toBe("Bearer decrypted:enc-admin-key");
expect(JSON.parse(init.body).model).toBe("gpt-4o-mini");
});
it("keeps the OpenAI wire for an OpenAI-compatible endpoint", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(
adminSettings({
adminAiBaseUrl: "https://llm.example.com/v1",
adminAiModel: "llama-3.1-70b",
}),
);
const provider = await resolveProvider("u-1");
expect(provider.type).toBe("admin-key");
await provider.generateCompletion(probe());
expect(fetchMock.mock.calls[0][0]).toBe(
"https://llm.example.com/v1/chat/completions",
);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe(
"llama-3.1-70b",
);
});
it("keeps the OpenAI default when no base URL is set", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(adminSettings());
const provider = await resolveProvider("u-1");
expect(provider.type).toBe("admin-key");
await provider.generateCompletion(probe());
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.openai.com/v1/chat/completions");
expect(JSON.parse(init.body).model).toBe("gpt-4o");
});
it("keeps the OpenAI path for a base URL that does not parse", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(
adminSettings({ adminAiBaseUrl: "api.anthropic.com" }),
);
const provider = await resolveProvider("u-1");
expect(provider.type).toBe("admin-key");
});
it("defaults the model to a Claude model on the Anthropic wire", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(
adminSettings({ adminAiBaseUrl: "https://api.anthropic.com/v1" }),
);
const provider = await resolveProvider("u-1");
await provider.generateCompletion(probe());
expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe(
"claude-sonnet-4-6",
);
});
});
+50 -1
View File
@@ -142,16 +142,65 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
}
}
/**
* True when a base URL addresses Anthropic's API: the host itself or any
* subdomain of `anthropic.com`, case-insensitively. Parsed rather than
* substring-matched, so `https://anthropic.com.attacker.example/v1` and
* `https://evil.example/?x=api.anthropic.com` both answer false.
*
* An unparseable value answers false and keeps the OpenAI path, which is the
* behaviour that value had before this function existed.
*/
function isAnthropicBaseUrl(baseUrl: string): boolean {
let host: string;
try {
host = new URL(baseUrl).hostname.toLowerCase();
} catch {
return false;
}
return host === "anthropic.com" || host.endsWith(".anthropic.com");
}
/**
* The operator's global provider — one key, one model, one base URL in the
* admin settings, serving every user who has configured nothing themselves.
*
* The base URL decides the wire. An operator who fills these fields with an
* Anthropic endpoint and an Anthropic key used to get OpenAI-shaped requests
* posted at Anthropic, which fails every call with nothing pointing at the
* cause; the host check routes them to the same `AnthropicClient` the
* per-user `ANTHROPIC` arm builds. Every other host stays on `OpenAIClient`,
* which is also the right client for an OpenAI-compatible endpoint (a local
* server, a gateway) — the admin base URL is host-allowlisted at write time
* in `/api/admin/ai-settings`.
*
* The chain still reports this entry as `admin-openai` whichever client it
* builds. That tag names the SLOT — "the operator's shared credential, last
* in the chain" — not the wire: it is persisted in `User.aiProviderChain`,
* offered in the chain editor, and pinned by the OpenAPI enum, so splitting
* it per wire would rewrite stored chains and widen a structural allowlist to
* express something the runtime provider type (`admin-key` / `anthropic` on
* the instance, which is what the wide events and the budget classifier read)
* already carries.
*/
async function resolveAdminProvider(): Promise<AIProvider> {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (settings?.adminAiKeyEncrypted) {
const baseUrl = settings.adminAiBaseUrl ?? "https://api.openai.com/v1";
if (isAnthropicBaseUrl(baseUrl)) {
return new AnthropicClient({
apiKey: decrypt(settings.adminAiKeyEncrypted),
model: settings.adminAiModel ?? "claude-sonnet-4-6",
baseUrl,
});
}
return new OpenAIClient({
apiKey: decrypt(settings.adminAiKeyEncrypted),
model: settings.adminAiModel ?? "gpt-4o",
baseUrl: settings.adminAiBaseUrl ?? "https://api.openai.com/v1",
baseUrl,
});
}