Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,33 @@ runs once per language. Tag tampering and original-header-byte tests remain.
|-------|----------|-------|
| `nonce` | yes | value the endpoint MUST echo to prove decryption |
| `phoneNumber` | yes | caller supplies an E.164 string; full E.164 validation is an implementation gap |
| `message` | yes | fully rendered, localized text containing the passcode; forward unchanged, including caller-supplied voice digit spacing. Do not extract, infer or guess a passcode |
| `message` | yes | fully rendered, localized text containing the passcode; forward unchanged when the adapter uses message text. Do not extract, infer or guess a passcode |
| `extension` | no | office-voice contract field; not currently forwarded by the shared dispatch model |
| `locale` | no | voice selection input where supported by the selected adapter |
| `riskContext` | no | contextual request data; no risk-policy evaluation is implemented here |
| `textToVoice` | for Soprano live voice | structured speech object supplied inside the encrypted context; see below |

Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `400`.

For Soprano live voice, include `textToVoice` alongside the required delivery fields:

```json
"textToVoice": {
"beforePasswordText": "Your verification code is",
"password": "001234",
"language": "en"
}
```

`beforePasswordText` must be a string (empty is allowed); `password` and `language` must be
nonblank strings. Supply the password explicitly to preserve leading zeros; it is never extracted
from `message`. These values are forwarded unchanged as `voice.text2voice`, without a top-level
`text` field. Missing or invalid speech returns `400` before credential lookup or provider HTTP.
SMS continues to use `message`, and evaluation continues to skip provider-specific validation and I/O.
Soprano authentication remains API-key-only (`X-MEMS-API-ID` and `X-MEMS-API-Key`, resolved from
`soprano-api-id` and `soprano-api-key` in Key Vault). No provider JWT, OAuth flow, token endpoint,
or bearer-token forwarding is added. Existing platform caller authentication is unchanged.

JWE provides payload confidentiality and integrity, **not SAS caller authentication**. Anyone with the
public key can encrypt a request. The nonce acknowledges decryption; it is not an authentication
credential or replay protection, and a fixed nonce cannot substitute for Easy Auth.
Expand Down
3 changes: 2 additions & 1 deletion dotnet/Functions/SendOtp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ ObjectResult Reply(int status, object body)
Channel: channel,
MessageId: clientRequestId,
CorrelationId: correlationId,
Locale: context.Locale);
Locale: context.Locale,
TextToVoice: context.TextToVoice);

// A nonce acknowledges delivery, not just decryption. Wait for the bounded provider call.
var result = await _engine.DispatchAsync(dispatch, requestId);
Expand Down
12 changes: 12 additions & 0 deletions dotnet/Src/DispatchEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ public sealed class DeliveryContext
[JsonPropertyName("locale")] public string? Locale { get; set; }
[JsonPropertyName("message")] public string? Message { get; set; }
[JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; }
[JsonPropertyName("textToVoice")] public TextToVoice? TextToVoice { get; set; }

[JsonIgnore]
public bool IsComplete => !string.IsNullOrWhiteSpace(Nonce)
Expand All @@ -114,6 +115,13 @@ public static DeliveryContext FromPayload(JsonElement payload)
if (payload.ValueKind != JsonValueKind.Object) return new();
string? ReadString(string name) => payload.TryGetProperty(name, out var value)
&& value.ValueKind == JsonValueKind.String ? value.GetString() : null;
TextToVoice? voice = null;
if (payload.TryGetProperty("textToVoice", out var speech) && speech.ValueKind == JsonValueKind.Object)
{
string? ReadVoiceString(string name) => speech.TryGetProperty(name, out var value)
&& value.ValueKind == JsonValueKind.String ? value.GetString() : null;
voice = new TextToVoice(ReadVoiceString("beforePasswordText"), ReadVoiceString("password"), ReadVoiceString("language"));
}
return new()
{
Nonce = ReadString("nonce"),
Expand All @@ -122,6 +130,7 @@ public static DeliveryContext FromPayload(JsonElement payload)
Extension = ReadString("extension"),
Locale = ReadString("locale"),
RiskContext = payload.TryGetProperty("riskContext", out var risk) ? risk.Clone() : null,
TextToVoice = voice,
};
}
}
Expand Down Expand Up @@ -230,6 +239,9 @@ public async Task<DispatchResult> DispatchAsync(DispatchRequest dispatch, string
if (!OutcomeMapper.DefaultChannels.Contains(channel))
return new DispatchResult(400, new { status = "error", provider = providerId, reason = "unsupported channel", requestId });

if (channel == "voice" && manifest.RequiresTextToVoice && dispatch.TextToVoice?.IsComplete != true)
return new DispatchResult(400, FailBody(providerId, channel, "incomplete voice context", dispatch, requestId));

if (manifest.Auth.Mode != "apiKey")
return new DispatchResult(502, FailBody(providerId, channel, "unsupported provider auth mode", dispatch, requestId));

Expand Down
18 changes: 16 additions & 2 deletions dotnet/Src/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@ public sealed record DispatchRequest(
string Channel,
string MessageId,
string? CorrelationId,
string? Locale);
string? Locale,
TextToVoice? TextToVoice = null);

public sealed record TextToVoice(
[property: JsonPropertyName("beforePasswordText")] string? BeforePasswordText,
[property: JsonPropertyName("password")] string? Password,
[property: JsonPropertyName("language")] string? Language)
{
[JsonIgnore]
public bool IsComplete => BeforePasswordText is not null
&& !string.IsNullOrWhiteSpace(Password) && !string.IsNullOrWhiteSpace(Language);

public override string ToString() => nameof(TextToVoice);
}

public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null);

Expand All @@ -43,7 +56,8 @@ public sealed record ParsedResponse(

public sealed record AuthConfig(string Mode, string? KeyVaultSecretName = null, string? IdentityKeyVaultSecretName = null);

public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDictionary<string, Outcome> ResponseMapping);
public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDictionary<string, Outcome> ResponseMapping,
bool RequiresTextToVoice = false);

public sealed record DispatchResult(int HttpStatus, object Body);

Expand Down
24 changes: 17 additions & 7 deletions dotnet/Src/Providers/SopranoProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public sealed class SopranoProvider : IProviderAdapter
["FILTERED"] = Outcome.Fail,
["BLOCKED"] = Outcome.Block,
["default"] = Outcome.Fail,
});
},
RequiresTextToVoice: true);

public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env)
{
Expand All @@ -31,14 +32,23 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc
["X-MEMS-API-ID"] = credential.Identity ?? string.Empty,
["X-MEMS-API-Key"] = credential.Secret ?? string.Empty,
};
var body = new
var body = new Dictionary<string, object?>
{
text = dispatch.Message,
destination = dispatch.Destination.TrimStart('+'),
messageTypes = new[] { channel == "voice" ? "voice" : "sms" },
correlationId = dispatch.CorrelationId ?? dispatch.MessageId,
shutterMode = false,
["destination"] = dispatch.Destination.TrimStart('+'),
["messageTypes"] = new[] { channel == "voice" ? "voice" : "sms" },
["correlationId"] = dispatch.CorrelationId ?? dispatch.MessageId,
["shutterMode"] = false,
};
if (channel == "voice")
{
var voice = dispatch.TextToVoice;
if (voice?.IsComplete != true) throw new InvalidOperationException("incomplete voice context");
body["voice"] = new { text2voice = voice };
}
else
{
body["text"] = dispatch.Message;
}

return new ProviderHttpRequest($"{endpoint.TrimEnd('/')}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body));
}
Expand Down
18 changes: 11 additions & 7 deletions dotnet/tests/ContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ private static DispatchRequest Request(string channel = "sms") =>
[InlineData("voice")]
public void SopranoUsesExactOmnimsgContract(string channel)
{
var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", Request(channel),
var dispatch = Request(channel) with { TextToVoice = new TextToVoice("Your code is", "001234", "en") };
var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", dispatch,
new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv());
Assert.Equal("https://provider.example/cgpapi/messages/omnimsg", request.Url);
Assert.Equal("POST", request.Method);
Expand All @@ -24,14 +25,17 @@ public void SopranoUsesExactOmnimsgContract(string channel)
Assert.Equal("test-key", request.Headers["X-MEMS-API-Key"]);
Assert.Equal("application/json", request.Headers["Accept"]);
Assert.Equal("application/json", request.Headers["Content-Type"]);
var expected = new
var expected = new Dictionary<string, object?>
{
text = Request().Message,
destination = "15551234567",
messageTypes = new[] { channel },
correlationId = "correlation-id",
shutterMode = false,
["destination"] = "15551234567",
["messageTypes"] = new[] { channel },
["correlationId"] = "correlation-id",
["shutterMode"] = false,
};
if (channel == "voice")
expected["voice"] = new { text2voice = new { beforePasswordText = "Your code is", password = "001234", language = "en" } };
else
expected["text"] = Request().Message;
Assert.Equal(JsonSerializer.Serialize(expected), request.Body);
}

Expand Down
37 changes: 34 additions & 3 deletions dotnet/tests/EngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate()
entered.TrySetResult();
return release.Task.WaitAsync(cancellation);
};
var pending = rig.Invoke(channel: "voice");
var voice = new { beforePasswordText = " Your code is ", password = "001234", language = "en" };
var pending = rig.Invoke(channel: "voice", deliveryOverrides: JsonSerializer.SerializeToElement(new { textToVoice = voice }));
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
Expand All @@ -43,15 +44,43 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate()
}
AssertAccepted(await pending);
using var body = JsonDocument.Parse(rig.Http.Body!);
Assert.Equal(Message, body.RootElement.GetProperty("text").GetString());
Assert.False(body.RootElement.TryGetProperty("text", out _));
Assert.Equal(JsonSerializer.Serialize(voice), body.RootElement.GetProperty("voice").GetProperty("text2voice").GetRawText());
Assert.Equal("voice", body.RootElement.GetProperty("messageTypes")[0].GetString());
Assert.Equal("private-api-id", rig.Http.Headers["X-MEMS-API-ID"]);
Assert.Equal("private-api-key", rig.Http.Headers["X-MEMS-API-Key"]);
Assert.False(rig.Http.Headers.ContainsKey("Authorization"));
Assert.Equal(1, rig.Http.Calls);
var log = Assert.Single(rig.Log.Messages);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Correlation)))[..16].ToLowerInvariant();
Assert.Contains("CorrelationId=" + hash, log);
foreach (var value in new[] { Phone, "918273", Nonce, Correlation, "private-api-key", "private-api-id" })
foreach (var value in new[] { Phone, "918273", "001234", Nonce, Correlation, "private-api-key", "private-api-id" })
Assert.DoesNotContain(value, log);
}

[Theory]
[InlineData("null")]
[InlineData("[]")]
[InlineData("{}")]
[InlineData("{\"beforePasswordText\":\"\",\"password\":123,\"language\":\"en\"}")]
[InlineData("{\"beforePasswordText\":null,\"password\":\"001234\",\"language\":\"en\"}")]
[InlineData("{\"beforePasswordText\":\"\",\"password\":\"001234\",\"language\":\" \"}")]
public async Task IncompleteVoiceFailsBeforeSecretsOrHttp(string speech)
{
using var rig = new HandlerRig();
var overrides = JsonSerializer.SerializeToElement(new { textToVoice = JsonSerializer.Deserialize<JsonElement>(speech) });
AssertFailure(rig, await rig.Invoke(channel: "voice", deliveryOverrides: overrides), 400);
Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls));
}

[Fact]
public void VoiceAllowsEmptyIntroAndKeepsDebugOutputPrivate()
{
var voice = new TextToVoice("", "001234", "en");
Assert.True(voice.IsComplete);
Assert.Equal("TextToVoice", voice.ToString());
}

[Fact]
public async Task FailedHttpCannotAcknowledgeAnAcceptedBodyOrLeakProviderText()
{
Expand Down Expand Up @@ -293,13 +322,15 @@ private sealed class TestHttp : HttpMessageHandler, IHttpClientFactory
{
public int Calls { get; private set; }
public string? Body { get; private set; }
public Dictionary<string, string> Headers { get; private set; } = new(StringComparer.OrdinalIgnoreCase);
public Func<CancellationToken, Task<HttpResponseMessage>> Respond { get; set; } =
_ => Task.FromResult(Json(201, "{\"status\":\"ACCEPTED\"}"));
public HttpClient CreateClient(string name) => new(this, disposeHandler: false);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
Body = await request.Content!.ReadAsStringAsync(cancellationToken);
Headers = request.Headers.ToDictionary(header => header.Key, header => string.Join(",", header.Value), StringComparer.OrdinalIgnoreCase);
return await Respond(cancellationToken);
}
}
Expand Down
8 changes: 7 additions & 1 deletion javascript/src/functions/dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const { compactDecrypt } = require('jose');
const { ManagedIdentityCredential } = require('@azure/identity');
const { SecretClient } = require('@azure/keyvault-secrets');
const { readConfig } = require('./config');
const { DeliveryContext } = require('./models');
const { DeliveryContext, TextToVoice } = require('./models');

const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' });
const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 });
Expand Down Expand Up @@ -131,6 +131,7 @@ function contextToDispatch(context, envelope, messageId) {
messageId,
correlationId: envelope.correlationId,
locale: context.locale || undefined,
textToVoice: context.textToVoice,
};
}

Expand Down Expand Up @@ -306,6 +307,11 @@ async function sendViaProvider(providerEntry, dispatch, options) {
return { httpStatus: 400, body: { status: 'error', reason: 'unsupported channel', requestId } };
}

if (channel === 'voice' && manifest.requiresTextToVoice
&& (!(dispatch.textToVoice instanceof TextToVoice) || !dispatch.textToVoice.isComplete)) {
return { httpStatus: 400, body: failBody(providerId, channel, 'incomplete voice context', dispatch, requestId) };
}

const endpointBaseUrl = config.providerEndpoint;
if (!isValidProviderUrl(endpointBaseUrl)) {
return { httpStatus: 502, body: failBody(providerId, channel, 'provider endpoint missing or invalid', dispatch, requestId) };
Expand Down
25 changes: 23 additions & 2 deletions javascript/src/functions/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,35 @@ const { inspect } = require('node:util');
* @property {*} locale
*/

class TextToVoice {
constructor({ beforePasswordText, password, language }) {
this.beforePasswordText = beforePasswordText;
this.password = password;
this.language = language;
}

static fromPayload(payload) {
return payload && typeof payload === 'object' && !Array.isArray(payload)
? new TextToVoice(payload) : null;
}

get isComplete() {
return typeof this.beforePasswordText === 'string'
&& [this.password, this.language].every(value => typeof value === 'string' && value.trim().length > 0);
}

[inspect.custom]() { return '[TextToVoice]'; }
}

class DeliveryContext {
constructor({ nonce, phoneNumber, message, extension, locale, riskContext }) {
constructor({ nonce, phoneNumber, message, extension, locale, riskContext, textToVoice = null }) {
this.nonce = nonce;
this.phoneNumber = phoneNumber;
this.message = message;
this.extension = extension;
this.locale = locale;
this.riskContext = riskContext;
this.textToVoice = TextToVoice.fromPayload(textToVoice);
}

static fromPayload(payload) {
Expand Down Expand Up @@ -72,4 +93,4 @@ class ParsedResponse {
[inspect.custom]() { return '[ParsedResponse]'; }
}

module.exports = { DeliveryContext, ParsedResponse };
module.exports = { DeliveryContext, TextToVoice, ParsedResponse };
11 changes: 9 additions & 2 deletions javascript/src/functions/providers/soprano.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

'use strict';

const { ParsedResponse } = require('../models');
const { ParsedResponse, TextToVoice } = require('../models');

const manifest = {
id: 'soprano',
requiresTextToVoice: true,
auth: {
mode: 'apiKey',
keyVaultSecretName: 'soprano-api-key',
Expand Down Expand Up @@ -40,12 +41,18 @@ function buildRequest({ channel, endpoint, dispatch, credential }) {
let destination = String(dispatch.destination || '');
while (destination.startsWith('+')) destination = destination.slice(1);
const body = {
text: dispatch.message,
destination,
messageTypes: [channel === 'voice' ? 'voice' : 'sms'],
correlationId: dispatch.correlationId || dispatch.messageId,
shutterMode: false,
};
if (channel === 'voice') {
const voice = dispatch.textToVoice;
if (!(voice instanceof TextToVoice) || !voice.isComplete) throw new Error('incomplete voice context');
body.voice = { text2voice: voice };
} else {
body.text = dispatch.message;
}
return { url: `${base}/messages/omnimsg`, method: 'POST', headers, body: JSON.stringify(body) };
}

Expand Down
Loading
Loading