import { BadRequestException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { credKeyFromEnv, sealSecret, openSecret, type SealedSecret } from './secret-crypto'; /** A provider config is an opaque JSON bag; each provider knows its own shape. */ export type ProviderConfig = Record; export interface CredentialStatus { configured: boolean; enabled?: boolean; hints?: Record; } /** Non-secret display fields surfaced by `status` — NEVER the secret itself. */ function hintsFor(providerType: string, config: ProviderConfig): Record { if (providerType === 'TWILIO_SMS') { const sid = String(config.accountSid ?? ''); return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) }; } return {}; } /** * The tenant's BYO integration credentials, one row per (scope, providerType). The secret is * sealed with AES-256-GCM under the platform key before it touches the DB; only non-secret * `displayHints` are ever read back. `resolve` decrypts on demand at send time; `status` never * returns the secret. Fail-closed: no platform key ⇒ upsert throws (we refuse to store plaintext). */ @Injectable() export class ProviderCredentialService { /** Overridable in tests; defaults to the process env (holds IIOS_CRED_KEY). */ env: NodeJS.ProcessEnv = process.env; constructor(private readonly prisma: PrismaService) {} async upsert(scopeId: string, providerType: string, config: ProviderConfig, opts?: { enabled?: boolean }): Promise { const key = credKeyFromEnv(this.env); if (!key) throw new BadRequestException('IIOS_CRED_KEY is not configured — cannot store integration credentials'); const sealed = sealSecret(JSON.stringify(config), key); const enabled = opts?.enabled ?? true; const displayHints = hintsFor(providerType, config) as Prisma.InputJsonValue; await this.prisma.iiosProviderCredential.upsert({ where: { scopeId_providerType: { scopeId, providerType } }, create: { scopeId, providerType, ...sealed, displayHints, enabled }, update: { ...sealed, displayHints, enabled }, }); } /** Decrypt the config for send-time use. Null when unconfigured, disabled, or no key. */ async resolve(scopeId: string, providerType: string): Promise { const row = await this.prisma.iiosProviderCredential.findUnique({ where: { scopeId_providerType: { scopeId, providerType } }, }); if (!row || !row.enabled) return null; const key = credKeyFromEnv(this.env); if (!key) return null; const sealed: SealedSecret = { cipherText: row.cipherText, iv: row.iv, authTag: row.authTag, keyVersion: row.keyVersion }; return JSON.parse(openSecret(sealed, key)) as ProviderConfig; } /** Masked view for the admin UI — configured + hints, never the secret. */ async status(scopeId: string, providerType: string): Promise { const row = await this.prisma.iiosProviderCredential.findUnique({ where: { scopeId_providerType: { scopeId, providerType } }, }); if (!row) return { configured: false }; return { configured: true, enabled: row.enabled, hints: (row.displayHints ?? {}) as Record }; } }