Files
iios/packages/iios-service/src/capability/provider-credential.service.ts
T
maaz519 23c43acf8f feat(capability): BYO SMTP — per-tenant email server via the credential registry
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>
2026-07-23 14:52:15 +05:30

74 lines
3.5 KiB
TypeScript

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<string, unknown>;
export interface CredentialStatus {
configured: boolean;
enabled?: boolean;
hints?: Record<string, unknown>;
}
/** Non-secret display fields surfaced by `status` — NEVER the secret itself. */
function hintsFor(providerType: string, config: ProviderConfig): Record<string, unknown> {
if (providerType === 'TWILIO_SMS') {
const sid = String(config.accountSid ?? '');
return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) };
}
if (providerType === 'SMTP') {
return { host: config.host ?? null, port: config.port ?? null, user: config.user ?? null, fromEmail: config.fromEmail ?? null, fromName: config.fromName ?? null };
}
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<void> {
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<ProviderConfig | null> {
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<CredentialStatus> {
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<string, unknown> };
}
}