23c43acf8f
SmtpProvider is now scope-aware: it resolves the tenant's own SMTP identity from the IiosProviderCredential store (providerType SMTP) and sends from it, falling back to the platform env identity when unset (env fallback applies only to the platform identity, never a tenant's server). Registered whenever env SMTP OR the credential store is present; fails closed as NOT_CONFIGURED when neither exists. Credential endpoints accept SMTP with per-type validation. 18 SMTP tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
77 lines
3.6 KiB
TypeScript
77 lines
3.6 KiB
TypeScript
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<string, unknown>): Record<string, unknown> {
|
|
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<string, unknown>,
|
|
@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);
|
|
}
|
|
}
|