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:
+25
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, CapabilityProvider>();
|
||||
|
||||
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<TwilioCreds | null>));
|
||||
}
|
||||
}
|
||||
|
||||
register(provider: CapabilityProvider): void {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, Record<string, unknown>>();
|
||||
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<typeof fakePrisma>, 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<typeof fakePrisma>;
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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<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) };
|
||||
}
|
||||
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> };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
@@ -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<TwilioCreds | null>;
|
||||
|
||||
/** Injectable HTTP sender so tests don't hit the network; prod uses fetch. */
|
||||
export type HttpSender = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
text: () => Promise<string>;
|
||||
}>;
|
||||
|
||||
const defaultSender: HttpSender = (url, init) => fetch(url, init) as unknown as ReturnType<HttpSender>;
|
||||
|
||||
/**
|
||||
* 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<ProviderResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user