import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from '@nestjs/common'; import { SessionVerifier } from '../platform/session.verifier'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { ProviderCredentialService } from './provider-credential.service'; const SUPPORTED = new Set(['TWILIO_SMS', 'SMTP']); const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : ''); const isEmail = (v: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v); /** Validate + shape the credential config for a provider type. Throws 400 on bad input. * be-crm does the strict client-facing validation; this is IIOS's own guard. */ function buildConfig(providerType: string, body: Record): Record { if (providerType === 'TWILIO_SMS') { const accountSid = str(body.accountSid); const authToken = str(body.authToken); const fromNumber = str(body.fromNumber); if (!accountSid || !authToken || !fromNumber) throw new BadRequestException('accountSid, authToken and fromNumber are required'); return { accountSid, authToken, fromNumber }; } if (providerType === 'SMTP') { const host = str(body.host); const user = str(body.user); const pass = str(body.pass); const fromEmail = str(body.fromEmail); const fromName = str(body.fromName); if (!host || !user || !pass || !fromEmail) throw new BadRequestException('host, user, pass and fromEmail are required'); if (!isEmail(fromEmail)) throw new BadRequestException('fromEmail must be a valid email'); return { host, port: Number(body.port) || 587, secure: body.secure === true || body.secure === 'true', user, pass, fromEmail, ...(fromName ? { fromName } : {}) }; } throw new BadRequestException(`unsupported providerType: ${providerType}`); } /** * Per-scope BYO integration credentials. The caller's attested session decides the scope, so a * tenant can only read/write ITS OWN provider credentials. The secret is sealed by the service; * GET returns a masked status (never the token). be-crm gates this behind tenant-admin policy. */ @Controller('v1/providers') export class ProviderCredentialController { constructor( private readonly session: SessionVerifier, private readonly actors: ActorResolver, private readonly credentials: ProviderCredentialService, ) {} @Put(':providerType/credentials') async put( @Param('providerType') providerType: string, @Body() body: Record, @Headers('authorization') authorization?: string, ) { this.assertSupported(providerType); const config = buildConfig(providerType, body ?? {}); const scope = await this.actors.resolveScope(this.principal(authorization)); await this.credentials.upsert(scope.id, providerType, config); return this.credentials.status(scope.id, providerType); } @Get(':providerType/credentials') async get(@Param('providerType') providerType: string, @Headers('authorization') authorization?: string) { this.assertSupported(providerType); const scope = await this.actors.resolveScope(this.principal(authorization)); return this.credentials.status(scope.id, providerType); } private assertSupported(providerType: string): void { if (!SUPPORTED.has(providerType)) throw new BadRequestException(`unsupported providerType: ${providerType}`); } private principal(authorization?: string): MessagePrincipal { const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); if (!token) throw new BadRequestException('Authorization bearer token is required'); return this.session.verify(token); } }