From d11019aebc83ea548bf4fe7ab94fa0f8734617bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:00:43 +0200 Subject: [PATCH] feat(ai): add the storage for an OpenAI-compatible gateway provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three dedicated User columns — base URL, optional bearer, model — for a provider whose endpoint the user chooses. They are separate from the OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must share no column with a user-supplied host, and a gateway must never receive the key issued for OpenAI. The bearer joins the encrypted-column registry and the rotation script; the three columns join the account-wipe reset set. --- .../migration.sql | 17 +++++++++++++++++ prisma/schema.prisma | 12 ++++++++++++ scripts/rotate-encryption-key.ts | 5 ++++- src/lib/crypto/encrypted-columns.ts | 4 ++++ src/lib/data-wipe/wipe-plan.ts | 3 +++ 5 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0280_openai_compatible_provider/migration.sql diff --git a/prisma/migrations/0280_openai_compatible_provider/migration.sql b/prisma/migrations/0280_openai_compatible_provider/migration.sql new file mode 100644 index 000000000..552a1d2e2 --- /dev/null +++ b/prisma/migrations/0280_openai_compatible_provider/migration.sql @@ -0,0 +1,17 @@ +-- The OpenAI-compatible gateway provider (#470): a user-configured endpoint +-- that speaks `/v1/chat/completions` — LiteLLM, OpenRouter, vLLM, or anything +-- else on that wire — with its own base URL, its own optional bearer, and its +-- own model. +-- +-- Three dedicated columns instead of reusing `ai_base_url` / the OpenAI key. +-- The OpenAI arm pins `api.openai.com` on purpose, so it must have no column +-- in common with a provider whose host the user chooses; and the gateway must +-- never receive the key the user issued for OpenAI. Separating the storage +-- makes both properties structural rather than a comment someone can delete. +-- +-- All three are nullable and additive: an install that never configures the +-- provider carries three NULLs and resolves exactly as before. + +ALTER TABLE "users" ADD COLUMN "ai_compat_base_url" TEXT; +ALTER TABLE "users" ADD COLUMN "ai_compat_key_encrypted" TEXT; +ALTER TABLE "users" ADD COLUMN "ai_compat_model" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 23ef6ad6b..367884b30 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -311,6 +311,18 @@ model User { // before storage. aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted") + // v1.33.1 (#470) — the OpenAI-compatible gateway provider (LiteLLM, + // OpenRouter, vLLM, any endpoint speaking `/v1/chat/completions`). + // Three DEDICATED columns rather than a reuse of `aiBaseUrl` / + // `aiOpenaiKeyEncrypted`: the OpenAI arm pins `api.openai.com` and must + // never be able to read a user-supplied host, and the gateway credential + // must never be the OpenAI key. Separate columns make that structural + // instead of a comment. The base URL passes the same SSRF floor as the + // Local provider (`isPublicUrl` unless the operator allowlisted the host). + aiCompatBaseUrl String? @map("ai_compat_base_url") + aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted") + aiCompatModel String? @map("ai_compat_model") + // v1.4.16 phase B5b — ordered fallback chain for multi-provider AI // redundancy. Each entry is `{ providerType, priority, enabled }`, // sorted by ascending priority at runtime. Credentials live in the diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 8fefe6ba2..06b10a0c5 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -170,7 +170,8 @@ async function main() { // "ouraClientSecretEncrypted" "stravaClientIdEncrypted" // "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted" // "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted" - // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted" + // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "aiCompatKeyEncrypted" + // "insuranceNumberEncrypted" const userFields = [ "codexAccessTokenEncrypted", "codexRefreshTokenEncrypted", @@ -200,6 +201,8 @@ async function main() { "aiAnthropicKeyEncrypted", "aiLocalKeyEncrypted", "aiOpenaiKeyEncrypted", + // v1.33.1 (#470) — the OpenAI-compatible gateway's optional bearer. + "aiCompatKeyEncrypted", "insuranceNumberEncrypted", // v1.23 — TOTP shared secret (second factor). "totpSecretEncrypted", diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 276062a1b..c8e9d5915 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -81,6 +81,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "User", field: "aiAnthropicKeyEncrypted", kind: "string" }, { model: "User", field: "aiLocalKeyEncrypted", kind: "string" }, { model: "User", field: "aiOpenaiKeyEncrypted", kind: "string" }, + // v1.33.1 (#470) — the OpenAI-compatible gateway provider's optional bearer. + // A dedicated column, never the OpenAI key: the gateway host is user-chosen, + // so the credential that reaches it must be one the user issued for it. + { model: "User", field: "aiCompatKeyEncrypted", kind: "string" }, { model: "User", field: "insuranceNumberEncrypted", kind: "string" }, // v1.23 — TOTP shared secret (second factor). String ciphertext like the // other User credential columns. diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 7ad5d753b..2004a7a35 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -262,6 +262,9 @@ export const USER_RESET = { aiAnthropicKeyEncrypted: null, aiLocalKeyEncrypted: null, aiOpenaiKeyEncrypted: null, + aiCompatBaseUrl: null, + aiCompatKeyEncrypted: null, + aiCompatModel: null, aiProviderChain: Prisma.DbNull, aiResponseTimeoutSeconds: null, disableCoach: false,