ddd628ab6f
IIOS becomes the tenant's integration/credential hub for anything it sends. New IiosProviderCredential (one row per scope+providerType) seals the secret with AES-256-GCM under IIOS_CRED_KEY (secret-crypto.ts); only non-secret displayHints are ever read back. ProviderCredentialService upsert/resolve/status; TwilioSmsProvider resolves the caller's own creds per scope at send time and POSTs to Twilio (fail-closed NOT_CONFIGURED when unset). Registered over the SMS sandbox only when the platform key + store are present. PUT/GET /v1/providers/:type/credentials (scope-fenced, masked reads). 16 new unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
68 lines
3.3 KiB
TypeScript
68 lines
3.3 KiB
TypeScript
import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
|
|
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
|
import { SandboxProvider } from './sandbox.provider';
|
|
import { HttpProvider } from './http.provider';
|
|
import { EmailProvider } from './email.provider';
|
|
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
|
|
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
|
|
import { credKeyFromEnv } from './secret-crypto';
|
|
import { ProviderCredentialService } from './provider-credential.service';
|
|
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
|
|
|
|
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
|
|
|
/**
|
|
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
|
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
|
|
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
|
|
* channels fail closed — no silent egress path.
|
|
*
|
|
* Precedence is registration ORDER (register() does Map.set → last wins). For EMAIL:
|
|
* sandbox → HTTP EmailProvider (if URL set) → SMTP (if SMTP env set), so SMTP > HTTP > sandbox.
|
|
*/
|
|
@Injectable()
|
|
export class CapabilityProviderRegistry {
|
|
private readonly byChannel = new Map<string, CapabilityProvider>();
|
|
|
|
constructor(
|
|
@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort,
|
|
@Optional() private readonly credentials?: ProviderCredentialService,
|
|
) {
|
|
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
|
for (const ch of DEFAULT_CHANNELS) {
|
|
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
|
if (!url) continue;
|
|
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
|
|
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
|
}
|
|
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments
|
|
// resolve through the media StoragePort when one is bound (else attachments FAIL closed).
|
|
const smtp = smtpIdentityFromEnv();
|
|
if (smtp) {
|
|
const resolver = this.storage ? storageResolver(this.storage) : undefined;
|
|
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
|
|
}
|
|
// BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered
|
|
// only when the platform key + credential store are present — else SMS stays on the sandbox.
|
|
if (credKeyFromEnv() && this.credentials) {
|
|
const creds = this.credentials;
|
|
this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise<TwilioCreds | null>));
|
|
}
|
|
}
|
|
|
|
register(provider: CapabilityProvider): void {
|
|
for (const ch of provider.channelTypes) this.byChannel.set(ch, provider);
|
|
}
|
|
|
|
forChannel(channelType: string): CapabilityProvider {
|
|
const p = this.byChannel.get(channelType);
|
|
if (!p) throw new NotFoundException(`no egress provider registered for channel: ${channelType}`);
|
|
return p;
|
|
}
|
|
|
|
/** Ops view: which provider backs each channel. */
|
|
bindings(): Record<string, string> {
|
|
return Object.fromEntries([...this.byChannel.entries()].map(([ch, p]) => [ch, p.name]));
|
|
}
|
|
}
|