diff --git a/packages/iios-service/prisma/migrations/20260722120000_provider_credentials/migration.sql b/packages/iios-service/prisma/migrations/20260722120000_provider_credentials/migration.sql new file mode 100644 index 0000000..3544210 --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260722120000_provider_credentials/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "IiosProviderCredential" ( + "id" TEXT NOT NULL, + "scopeId" TEXT NOT NULL, + "providerType" TEXT NOT NULL, + "cipherText" TEXT NOT NULL, + "iv" TEXT NOT NULL, + "authTag" TEXT NOT NULL, + "keyVersion" INTEGER NOT NULL DEFAULT 1, + "displayHints" JSONB, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IiosProviderCredential_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IiosProviderCredential_scopeId_idx" ON "IiosProviderCredential"("scopeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "IiosProviderCredential_scopeId_providerType_key" ON "IiosProviderCredential"("scopeId", "providerType"); + +-- AddForeignKey +ALTER TABLE "IiosProviderCredential" ADD CONSTRAINT "IiosProviderCredential_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index bda1038..799aeeb 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -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 { diff --git a/packages/iios-service/src/capability/capability.module.ts b/packages/iios-service/src/capability/capability.module.ts index 6eac20c..4c27fa0 100644 --- a/packages/iios-service/src/capability/capability.module.ts +++ b/packages/iios-service/src/capability/capability.module.ts @@ -2,15 +2,20 @@ import { Module } from '@nestjs/common'; import { MediaModule } from '../media/media.module'; import { CapabilityProviderRegistry } from './capability.registry'; import { CapabilityBroker } from './capability.broker'; +import { ProviderCredentialService } from './provider-credential.service'; +import { ProviderCredentialController } from './provider-credential.controller'; /** * The governed egress boundary (P9 slice 1). Exports the broker + registry so the * outbound layer routes all sends through policy + obligations + a pluggable * provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule. + * Also owns per-scope BYO provider credentials (Twilio SMS, …) — sealed at rest and + * resolved by the channel providers at send time. */ @Module({ imports: [MediaModule], - providers: [CapabilityProviderRegistry, CapabilityBroker], - exports: [CapabilityBroker, CapabilityProviderRegistry], + controllers: [ProviderCredentialController], + providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService], + exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService], }) export class CapabilityModule {} diff --git a/packages/iios-service/src/capability/capability.registry.ts b/packages/iios-service/src/capability/capability.registry.ts index 4728e7a..76b32a0 100644 --- a/packages/iios-service/src/capability/capability.registry.ts +++ b/packages/iios-service/src/capability/capability.registry.ts @@ -4,6 +4,9 @@ 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']; @@ -21,7 +24,10 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; export class CapabilityProviderRegistry { private readonly byChannel = new Map(); - constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) { + 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}`]; @@ -36,6 +42,12 @@ export class CapabilityProviderRegistry { 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)); + } } register(provider: CapabilityProvider): void { diff --git a/packages/iios-service/src/capability/provider-credential.controller.ts b/packages/iios-service/src/capability/provider-credential.controller.ts new file mode 100644 index 0000000..8b69fb1 --- /dev/null +++ b/packages/iios-service/src/capability/provider-credential.controller.ts @@ -0,0 +1,54 @@ +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'; +import { TwilioCredentialsDto } from './provider-credential.dto'; + +const SUPPORTED = new Set(['TWILIO_SMS']); + +/** + * 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: TwilioCredentialsDto, + @Headers('authorization') authorization?: string, + ) { + this.assertSupported(providerType); + const scope = await this.actors.resolveScope(this.principal(authorization)); + await this.credentials.upsert(scope.id, providerType, { + accountSid: body.accountSid, + authToken: body.authToken, + fromNumber: body.fromNumber, + }); + 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); + } +} diff --git a/packages/iios-service/src/capability/provider-credential.dto.ts b/packages/iios-service/src/capability/provider-credential.dto.ts new file mode 100644 index 0000000..75c88e9 --- /dev/null +++ b/packages/iios-service/src/capability/provider-credential.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * PUT /v1/providers/TWILIO_SMS/credentials — a tenant's own Twilio credentials. Step 1 supports + * TWILIO_SMS only; when a second provider (SMTP, …) is added, switch to a per-type validated body. + */ +export class TwilioCredentialsDto { + @IsString() @IsNotEmpty() accountSid!: string; + @IsString() @IsNotEmpty() authToken!: string; + @IsString() @IsNotEmpty() fromNumber!: string; +} diff --git a/packages/iios-service/src/capability/provider-credential.service.spec.ts b/packages/iios-service/src/capability/provider-credential.service.spec.ts new file mode 100644 index 0000000..41aff22 --- /dev/null +++ b/packages/iios-service/src/capability/provider-credential.service.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ProviderCredentialService } from './provider-credential.service'; + +const KEY_B64 = Buffer.alloc(32, 7).toString('base64'); + +/** Minimal in-memory stand-in for prisma.iiosProviderCredential (upsert/findUnique). */ +function fakePrisma() { + const rows = new Map>(); + const k = (scopeId: string, providerType: string) => `${scopeId}::${providerType}`; + return { + _rows: rows, + iiosProviderCredential: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async upsert({ where, create, update }: any) { + const key = k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType); + const existing = rows.get(key); + const row = existing ? { ...existing, ...update } : { ...create }; + rows.set(key, row); + return row; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async findUnique({ where }: any) { + return rows.get(k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType)) ?? null; + }, + }, + }; +} + +function make(prisma: ReturnType, env: NodeJS.ProcessEnv): ProviderCredentialService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = new ProviderCredentialService(prisma as any); + s.env = env; + return s; +} + +describe('ProviderCredentialService', () => { + let prisma: ReturnType; + let svc: ProviderCredentialService; + + beforeEach(() => { + prisma = fakePrisma(); + svc = make(prisma, { IIOS_CRED_KEY: KEY_B64 } as NodeJS.ProcessEnv); + }); + + it('seals the secret at rest — plaintext token never stored', async () => { + await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' }); + const stored = JSON.stringify([...prisma._rows.values()]); + expect(stored).not.toContain('tok_secret'); + expect(stored).toContain('cipherText'); + }); + + it('resolves back the exact config it sealed', async () => { + const cfg = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' }; + await svc.upsert('scope_1', 'TWILIO_SMS', cfg); + expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toEqual(cfg); + }); + + it('status is masked — reveals hints, never the token', async () => { + await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC1234567', authToken: 'tok_secret', fromNumber: '+15550001111' }); + const status = await svc.status('scope_1', 'TWILIO_SMS'); + expect(status).toMatchObject({ configured: true, enabled: true, hints: { fromNumber: '+15550001111', sidLast4: '4567' } }); + expect(JSON.stringify(status)).not.toContain('tok_secret'); + }); + + it('reports not-configured for an unknown scope', async () => { + expect(await svc.status('nope', 'TWILIO_SMS')).toEqual({ configured: false }); + expect(await svc.resolve('nope', 'TWILIO_SMS')).toBeNull(); + }); + + it('does not resolve a disabled credential', async () => { + await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 't', fromNumber: '+1' }, { enabled: false }); + expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toBeNull(); + }); + + it('throws when the platform key is absent (fail closed, never store plaintext)', async () => { + const noKey = make(prisma, {} as NodeJS.ProcessEnv); + await expect(noKey.upsert('s', 'TWILIO_SMS', { accountSid: 'A', authToken: 't', fromNumber: '+1' })).rejects.toThrow(/IIOS_CRED_KEY/); + }); +}); diff --git a/packages/iios-service/src/capability/provider-credential.service.ts b/packages/iios-service/src/capability/provider-credential.service.ts new file mode 100644 index 0000000..574913b --- /dev/null +++ b/packages/iios-service/src/capability/provider-credential.service.ts @@ -0,0 +1,70 @@ +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; + +export interface CredentialStatus { + configured: boolean; + enabled?: boolean; + hints?: Record; +} + +/** Non-secret display fields surfaced by `status` — NEVER the secret itself. */ +function hintsFor(providerType: string, config: ProviderConfig): Record { + if (providerType === 'TWILIO_SMS') { + const sid = String(config.accountSid ?? ''); + return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) }; + } + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/packages/iios-service/src/capability/secret-crypto.spec.ts b/packages/iios-service/src/capability/secret-crypto.spec.ts new file mode 100644 index 0000000..2a17965 --- /dev/null +++ b/packages/iios-service/src/capability/secret-crypto.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { credKeyFromEnv, sealSecret, openSecret, SECRET_KEY_VERSION } from './secret-crypto'; + +const KEY = Buffer.alloc(32, 7); // deterministic 32-byte key for tests + +describe('secret-crypto', () => { + it('round-trips a secret through seal → open', () => { + const sealed = sealSecret('sk_live_abc123', KEY); + expect(sealed.keyVersion).toBe(SECRET_KEY_VERSION); + expect(sealed.cipherText).not.toContain('sk_live_abc123'); + expect(openSecret(sealed, KEY)).toBe('sk_live_abc123'); + }); + + it('produces a distinct ciphertext each time (random IV)', () => { + const a = sealSecret('same', KEY); + const b = sealSecret('same', KEY); + expect(a.cipherText).not.toBe(b.cipherText); + expect(a.iv).not.toBe(b.iv); + }); + + it('fails to open with the wrong key', () => { + const sealed = sealSecret('top-secret', KEY); + expect(() => openSecret(sealed, Buffer.alloc(32, 9))).toThrow(); + }); + + it('fails to open if the ciphertext is tampered (GCM auth)', () => { + const sealed = sealSecret('top-secret', KEY); + const bad = { ...sealed, cipherText: Buffer.from('deadbeef', 'hex').toString('base64') }; + expect(() => openSecret(bad, KEY)).toThrow(); + }); + + it('credKeyFromEnv returns null when unset and rejects a wrong-length key', () => { + expect(credKeyFromEnv({})).toBeNull(); + expect(() => credKeyFromEnv({ IIOS_CRED_KEY: Buffer.alloc(16).toString('base64') })).toThrow(/32 bytes/); + const key = credKeyFromEnv({ IIOS_CRED_KEY: KEY.toString('base64') }); + expect(key?.length).toBe(32); + }); +}); diff --git a/packages/iios-service/src/capability/secret-crypto.ts b/packages/iios-service/src/capability/secret-crypto.ts new file mode 100644 index 0000000..403606c --- /dev/null +++ b/packages/iios-service/src/capability/secret-crypto.ts @@ -0,0 +1,46 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +/** + * Envelope for a tenant secret sealed with AES-256-GCM. Stored as base64 columns on + * IiosProviderCredential; the plaintext (a provider config JSON string) never touches disk. + */ +export interface SealedSecret { + cipherText: string; + iv: string; + authTag: string; + keyVersion: number; +} + +/** Bumped only when the platform master key rotates; lets old rows decrypt under an old key. */ +export const SECRET_KEY_VERSION = 1; + +/** + * Resolve the 32-byte platform master key from `IIOS_CRED_KEY` (base64). Returns null when + * unset (so egress providers that need it stay unregistered / fail closed) but THROWS on a + * present-but-malformed key — a wrong-length key is an operator error, not a "disabled" state. + */ +export function credKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Buffer | null { + const raw = env.IIOS_CRED_KEY; + if (!raw) return null; + const key = Buffer.from(raw, 'base64'); + if (key.length !== 32) throw new Error('IIOS_CRED_KEY must decode to 32 bytes (base64-encoded 256-bit key)'); + return key; +} + +export function sealSecret(plaintext: string, key: Buffer): SealedSecret { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return { + cipherText: enc.toString('base64'), + iv: iv.toString('base64'), + authTag: cipher.getAuthTag().toString('base64'), + keyVersion: SECRET_KEY_VERSION, + }; +} + +export function openSecret(sealed: SealedSecret, key: Buffer): string { + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64')); + decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(sealed.cipherText, 'base64')), decipher.final()]).toString('utf8'); +} diff --git a/packages/iios-service/src/capability/twilio-sms.provider.spec.ts b/packages/iios-service/src/capability/twilio-sms.provider.spec.ts new file mode 100644 index 0000000..4c25bd5 --- /dev/null +++ b/packages/iios-service/src/capability/twilio-sms.provider.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { CapabilityRequest } from '@insignia/iios-contracts'; +import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider'; + +const CREDS: TwilioCreds = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' }; + +const req = (over: Partial = {}): CapabilityRequest => ({ + capability: 'channel.send', + channelType: 'SMS', + scopeId: 'scope_1', + target: '+15557654321', + payload: { text: 'hello world' }, + idempotencyKey: 'idem-1', + ...over, +}); + +describe('TwilioSmsProvider', () => { + it('resolves the scope creds and POSTs to Twilio, returning SENT with the message SID', async () => { + const http = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ sid: 'SM999' }), text: async () => '' }); + const resolve = vi.fn().mockResolvedValue(CREDS); + const p = new TwilioSmsProvider(resolve, http); + + const res = await p.send(req()); + + expect(resolve).toHaveBeenCalledWith('scope_1'); + const [url, init] = http.mock.calls[0]; + expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json'); + expect(init.method).toBe('POST'); + expect(init.headers.authorization).toBe(`Basic ${Buffer.from('AC123:tok_secret').toString('base64')}`); + const body = new URLSearchParams(init.body as string); + expect(body.get('To')).toBe('+15557654321'); + expect(body.get('From')).toBe('+15550001111'); + expect(body.get('Body')).toBe('hello world'); + expect(res).toMatchObject({ outcome: 'SENT', providerRef: 'SM999' }); + }); + + it('fails closed as NOT_CONFIGURED when the scope has no creds', async () => { + const http = vi.fn(); + const p = new TwilioSmsProvider(async () => null, http); + const res = await p.send(req()); + expect(res.outcome).toBe('FAILED'); + expect(res.errorCode).toBe('NOT_CONFIGURED'); + expect(http).not.toHaveBeenCalled(); + }); + + it('fails closed as NOT_CONFIGURED when the request has no scope', async () => { + const p = new TwilioSmsProvider(async () => CREDS, vi.fn()); + const res = await p.send(req({ scopeId: undefined })); + expect(res.outcome).toBe('FAILED'); + expect(res.errorCode).toBe('NOT_CONFIGURED'); + }); + + it('surfaces a Twilio API error as FAILED (never throws)', async () => { + const http = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ code: 20003, message: 'Authenticate' }), text: async () => 'Authenticate' }); + const p = new TwilioSmsProvider(async () => CREDS, http); + const res = await p.send(req()); + expect(res.outcome).toBe('FAILED'); + expect(res.errorCode).toBe('TWILIO_20003'); + }); + + it('surfaces a transport throw as FAILED', async () => { + const http = vi.fn().mockRejectedValue(new Error('network down')); + const p = new TwilioSmsProvider(async () => CREDS, http); + const res = await p.send(req()); + expect(res.outcome).toBe('FAILED'); + }); +}); diff --git a/packages/iios-service/src/capability/twilio-sms.provider.ts b/packages/iios-service/src/capability/twilio-sms.provider.ts new file mode 100644 index 0000000..d1a2d4b --- /dev/null +++ b/packages/iios-service/src/capability/twilio-sms.provider.ts @@ -0,0 +1,70 @@ +import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts'; + +/** The decrypted Twilio config a tenant configures (BYO credentials). */ +export interface TwilioCreds { + accountSid: string; + authToken: string; + fromNumber: string; +} + +/** Resolves a scope's Twilio creds (decrypting on demand); null when unconfigured/disabled. */ +export type TwilioCredResolver = (scopeId: string) => Promise; + +/** Injectable HTTP sender so tests don't hit the network; prod uses fetch. */ +export type HttpSender = (url: string, init: { method: string; headers: Record; body: string }) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}>; + +const defaultSender: HttpSender = (url, init) => fetch(url, init) as unknown as ReturnType; + +/** + * SMS egress via Twilio, using the CALLER'S OWN credentials (resolved per scope at send time). + * Fail-closed: no scope or no configured creds ⇒ FAILED/NOT_CONFIGURED, nothing sent. A Twilio + * API error is surfaced as FAILED (never thrown), per the provider doctrine. + */ +export class TwilioSmsProvider implements CapabilityProvider { + readonly name = 'twilio-sms'; + readonly channelTypes = ['SMS']; + readonly capabilities = { canSend: true, maxPayloadBytes: 1600 }; + + constructor( + private readonly resolve: TwilioCredResolver, + private readonly http: HttpSender = defaultSender, + ) {} + + async send(req: CapabilityRequest): Promise { + const started = Date.now(); + if (!req.scopeId) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' }; + + const creds = await this.resolve(req.scopeId).catch(() => null); + if (!creds) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' }; + + const body = new URLSearchParams({ + To: req.target, + From: creds.fromNumber, + Body: String(req.payload.text ?? req.payload.body ?? ''), + }).toString(); + + try { + const res = await this.http(`https://api.twilio.com/2010-04-01/Accounts/${creds.accountSid}/Messages.json`, { + method: 'POST', + headers: { + authorization: `Basic ${Buffer.from(`${creds.accountSid}:${creds.authToken}`).toString('base64')}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body, + }); + const latencyMs = Date.now() - started; + const payload = (await res.json().catch(() => ({}))) as { sid?: string; code?: number; message?: string }; + if (!res.ok) { + return { providerRef: `twilio-${res.status}`, outcome: 'FAILED', errorCode: payload.code ? `TWILIO_${payload.code}` : `HTTP_${res.status}`, latencyMs }; + } + return { providerRef: payload.sid ?? 'twilio-sent', outcome: 'SENT', latencyMs }; + } catch (err) { + return { providerRef: 'twilio-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started }; + } + } +}