feat(capability): per-scope BYO provider credentials + Twilio SMS egress

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>
This commit is contained in:
2026-07-22 15:10:18 +05:30
parent 2aa2893973
commit ddd628ab6f
12 changed files with 503 additions and 3 deletions
@@ -317,10 +317,33 @@ model IiosScope {
callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
messageTemplates IiosMessageTemplate[]
providerCredentials IiosProviderCredential[]
@@index([orgId, appId, tenantId])
}
/// A tenant's own credentials for an egress provider (BYO: Twilio SMS, own SMTP, …).
/// The secret is sealed with AES-256-GCM under the platform key (IIOS_CRED_KEY); only
/// `displayHints` (non-secret, e.g. from-number / SID last-4) is ever read back out.
/// One row per (scope, providerType). Resolved at send time by the channel's provider.
model IiosProviderCredential {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
providerType String // TWILIO_SMS | SMTP | WHATSAPP_CLOUD | …
cipherText String // base64(AES-256-GCM ciphertext of the JSON config)
iv String // base64 nonce
authTag String // base64 GCM auth tag
keyVersion Int @default(1)
displayHints Json? // non-secret display fields (fromNumber, sidLast4, …)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, providerType])
@@index([scopeId])
}
/// A channel-specific handle (phone/email/portal user). NOT identity — stays
/// UNVERIFIED until MDM resolves it; no silent merge.
model IiosSourceHandle {