fix(api): resolve Docker startup failures (secrets, Redis, Prisma)

- Pass BETTER_AUTH_SECRET through all 6 docker-compose files to API container
- Fix BullModule to parse VALKEY_URL instead of VALKEY_HOST/VALKEY_PORT,
  matching all other Redis consumers in the codebase
- Migrate Prisma encryption from removed $use() middleware to $extends()
  client extensions (Prisma 6.x compatibility), keeping extends PrismaClient
  pattern with only account and llmProviderInstance getters overridden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 11:04:04 -06:00
parent 7b892d5197
commit 0ca3945061
11 changed files with 314 additions and 17 deletions

View File

@@ -60,10 +60,13 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
}),
// BullMQ job queue configuration
BullModule.forRoot({
connection: {
host: process.env.VALKEY_HOST ?? "localhost",
port: parseInt(process.env.VALKEY_PORT ?? "6379", 10),
},
connection: (() => {
const url = new URL(process.env.VALKEY_URL ?? "redis://localhost:6379");
return {
host: url.hostname,
port: parseInt(url.port || "6379", 10),
};
})(),
}),
TelemetryModule,
PrismaModule,

View File

@@ -0,0 +1,144 @@
/**
* Account Encryption Extension
*
* Prisma Client Extension that transparently encrypts/decrypts OAuth tokens
* in the Account table using VaultService (OpenBao Transit or AES-256-GCM fallback).
*
* Migrated from $use() middleware (removed in Prisma 5+) to $extends() API.
*/
import { Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { VaultService } from "../vault/vault.service";
import { TransitKey } from "../vault/vault.constants";
const TOKEN_FIELDS = ["accessToken", "refreshToken", "idToken"] as const;
interface AccountData extends Record<string, unknown> {
accessToken?: string | null;
refreshToken?: string | null;
idToken?: string | null;
encryptionVersion?: string | null;
}
export function createAccountEncryptionExtension(vaultService: VaultService) {
const logger = new Logger("AccountEncryptionExtension");
return Prisma.defineExtension({
name: "account-encryption",
query: {
account: {
async create({ args, query }) {
await encryptTokens(args.data as AccountData, vaultService);
return query(args);
},
async update({ args, query }) {
await encryptTokens(args.data as AccountData, vaultService);
return query(args);
},
async updateMany({ args, query }) {
await encryptTokens(args.data as AccountData, vaultService);
return query(args);
},
async upsert({ args, query }) {
await encryptTokens(args.create as AccountData, vaultService);
await encryptTokens(args.update as AccountData, vaultService);
return query(args);
},
async findUnique({ args, query }) {
const result = await query(args);
if (result && typeof result === "object") {
await decryptTokens(result as AccountData, vaultService, logger);
}
return result;
},
async findFirst({ args, query }) {
const result = await query(args);
if (result && typeof result === "object") {
await decryptTokens(result as AccountData, vaultService, logger);
}
return result;
},
async findMany({ args, query }) {
const results = await query(args);
for (const account of results) {
await decryptTokens(account as AccountData, vaultService, logger);
}
return results;
},
},
},
});
}
async function encryptTokens(data: AccountData, vaultService: VaultService): Promise<void> {
let encrypted = false;
let encryptionVersion: "aes" | "vault" | null = null;
for (const field of TOKEN_FIELDS) {
const value = data[field];
if (value == null) continue;
if (typeof value === "string" && isEncrypted(value)) continue;
if (typeof value === "string") {
const ciphertext = await vaultService.encrypt(value, TransitKey.ACCOUNT_TOKENS);
data[field] = ciphertext;
encrypted = true;
encryptionVersion = ciphertext.startsWith("vault:v1:") ? "vault" : "aes";
}
}
if (encrypted && encryptionVersion) {
data.encryptionVersion = encryptionVersion;
}
}
async function decryptTokens(
account: AccountData,
vaultService: VaultService,
_logger: Logger
): Promise<void> {
const shouldDecrypt =
account.encryptionVersion === "aes" || account.encryptionVersion === "vault";
for (const field of TOKEN_FIELDS) {
const value = account[field];
if (value == null) continue;
if (typeof value === "string") {
if (shouldDecrypt) {
try {
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}`
);
}
} else if (!account.encryptionVersion && isEncrypted(value)) {
try {
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}`
);
}
}
}
}
}
function isEncrypted(value: string): boolean {
if (!value || typeof value !== "string") return false;
if (isAESEncrypted(value)) return true;
if (value.startsWith("vault:v1:")) return true;
return false;
}
function isAESEncrypted(value: string): boolean {
if (!value || typeof value !== "string") return false;
const parts = value.split(":");
if (parts.length !== 3) return false;
return parts.every((part) => /^[0-9a-f]+$/i.test(part));
}

View File

@@ -0,0 +1,119 @@
/**
* LLM Encryption Extension
*
* Prisma Client Extension that transparently encrypts/decrypts LLM provider API keys
* in the LlmProviderInstance.config JSON field using VaultService.
*
* Migrated from $use() middleware (removed in Prisma 5+) to $extends() API.
*/
import { Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { VaultService } from "../vault/vault.service";
import { TransitKey } from "../vault/vault.constants";
interface LlmProviderInstanceData extends Record<string, unknown> {
config?: LlmProviderConfig;
}
interface LlmProviderConfig {
apiKey?: string | null;
endpoint?: string;
[key: string]: unknown;
}
export function createLlmEncryptionExtension(vaultService: VaultService) {
const logger = new Logger("LlmEncryptionExtension");
return Prisma.defineExtension({
name: "llm-encryption",
query: {
llmProviderInstance: {
async create({ args, query }) {
await encryptConfig(args.data as LlmProviderInstanceData, vaultService);
return query(args);
},
async update({ args, query }) {
await encryptConfig(args.data as LlmProviderInstanceData, vaultService);
return query(args);
},
async updateMany({ args, query }) {
await encryptConfig(args.data as LlmProviderInstanceData, vaultService);
return query(args);
},
async upsert({ args, query }) {
await encryptConfig(args.create as LlmProviderInstanceData, vaultService);
await encryptConfig(args.update as LlmProviderInstanceData, vaultService);
return query(args);
},
async findUnique({ args, query }) {
const result = await query(args);
if (result && typeof result === "object") {
await decryptConfig(result as LlmProviderInstanceData, vaultService, logger);
}
return result;
},
async findFirst({ args, query }) {
const result = await query(args);
if (result && typeof result === "object") {
await decryptConfig(result as LlmProviderInstanceData, vaultService, logger);
}
return result;
},
async findMany({ args, query }) {
const results = await query(args);
for (const instance of results) {
await decryptConfig(instance as LlmProviderInstanceData, vaultService, logger);
}
return results;
},
},
},
});
}
async function encryptConfig(
data: LlmProviderInstanceData,
vaultService: VaultService
): Promise<void> {
if (!data.config || typeof data.config !== "object") return;
const config = data.config;
if (!config.apiKey || typeof config.apiKey !== "string") return;
if (isEncrypted(config.apiKey)) return;
config.apiKey = await vaultService.encrypt(config.apiKey, TransitKey.LLM_CONFIG);
}
async function decryptConfig(
instance: LlmProviderInstanceData,
vaultService: VaultService,
_logger: Logger
): Promise<void> {
if (!instance.config || typeof instance.config !== "object") return;
const config = instance.config;
if (!config.apiKey || typeof config.apiKey !== "string") return;
if (!isEncrypted(config.apiKey)) return;
try {
config.apiKey = await vaultService.decrypt(config.apiKey, TransitKey.LLM_CONFIG);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to decrypt LLM provider configuration. Please re-enter the API key. Details: ${errorMsg}`
);
}
}
function isEncrypted(value: string): boolean {
if (!value || typeof value !== "string") return false;
if (value.startsWith("vault:v1:")) return true;
if (isAESEncrypted(value)) return true;
return false;
}
function isAESEncrypted(value: string): boolean {
if (!value || typeof value !== "string") return false;
const parts = value.split(":");
if (parts.length !== 3) return false;
return parts.every((part) => /^[0-9a-f]+$/i.test(part));
}

View File

@@ -48,10 +48,14 @@ describe("PrismaService", () => {
});
describe("onModuleInit", () => {
it("should connect to the database", async () => {
it("should connect to the database and register encryption extensions", async () => {
const connectSpy = vi.spyOn(service, "$connect").mockResolvedValue(undefined);
// Mock $use to prevent middleware registration errors in tests
(service as any).$use = vi.fn();
// Mock $extends to return a mock client (extensions chain)
const mockExtendedClient = { account: {}, llmProviderInstance: {} };
vi.spyOn(service, "$extends").mockReturnValue({
...mockExtendedClient,
$extends: vi.fn().mockReturnValue(mockExtendedClient),
} as never);
await service.onModuleInit();

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
import { VaultService } from "../vault/vault.service";
import { registerAccountEncryptionMiddleware } from "./account-encryption.middleware";
import { registerLlmEncryptionMiddleware } from "./llm-encryption.middleware";
import { createAccountEncryptionExtension } from "./account-encryption.extension";
import { createLlmEncryptionExtension } from "./llm-encryption.extension";
/**
* Prisma service that manages database connection lifecycle
@@ -11,11 +11,20 @@ import { registerLlmEncryptionMiddleware } from "./llm-encryption.middleware";
* IMPORTANT: VaultService is required (not optional) for encryption/decryption
* of sensitive Account tokens. It automatically falls back to AES-256-GCM when
* OpenBao is unavailable.
*
* Encryption is handled via Prisma Client Extensions ($extends) on the `account`
* and `llmProviderInstance` models. The extended client is stored in `_xClient`
* and only those two model getters are overridden — all other models use the
* base PrismaClient inheritance, preserving full type safety.
*/
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
// Extended client with encryption hooks for account + llmProviderInstance
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private _xClient: any = null;
constructor(private readonly vaultService: VaultService) {
super({
log: process.env.NODE_ENV === "development" ? ["query", "info", "warn", "error"] : ["error"],
@@ -30,20 +39,30 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
await this.$connect();
this.logger.log("Database connection established");
// Register Account token encryption middleware
// VaultService provides OpenBao Transit encryption with AES-256-GCM fallback
registerAccountEncryptionMiddleware(this, this.vaultService);
this.logger.log("Account encryption middleware registered");
// Register LLM provider API key encryption middleware
registerLlmEncryptionMiddleware(this, this.vaultService);
this.logger.log("LLM encryption middleware registered");
// Register encryption extensions (replaces removed $use() middleware)
this._xClient = this.$extends(createAccountEncryptionExtension(this.vaultService)).$extends(
createLlmEncryptionExtension(this.vaultService)
);
this.logger.log("Encryption extensions registered (Account, LlmProviderInstance)");
} catch (error) {
this.logger.error("Failed to connect to database", error);
throw error;
}
}
// Override only the 2 models that need encryption hooks.
// All other models (user, task, workspace, etc.) use base PrismaClient via inheritance.
// Cast _xClient to PrismaClient to preserve the accessor return types for consumers.
override get account() {
return this._xClient ? (this._xClient as PrismaClient).account : super.account;
}
override get llmProviderInstance() {
if (this._xClient) return (this._xClient as PrismaClient).llmProviderInstance;
return super.llmProviderInstance;
}
/**
* Disconnect from database when NestJS module is destroyed
*/