Skip to content
Merged
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
28 changes: 16 additions & 12 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ module keyvault 'modules/keyvault.bicep' = {
}
}

// Notification Hubs Free tier — namespace + hub. The connection string is
// consumed by the Function App as an app setting (DeviceApi for installation
// upserts; Phase-2+ PushDelivery for sends). APNs .p8 upload remains manual
// — Bicep doesn't accept the file contents directly.
module notificationHub 'modules/notificationhub.bicep' = {
name: 'notification-hub-${env}'
params: {
location: location
namePrefix: namePrefix
env: env
tags: tags
}
}

// Function App + Storage + App Insights (all five Functions live here).
module functions 'modules/functions.bicep' = {
name: 'functions-${env}'
Expand All @@ -77,6 +91,8 @@ module functions 'modules/functions.bicep' = {
tags: tags
cosmosAccountName: cosmos.outputs.accountName
keyVaultName: keyvault.outputs.vaultName
notificationHubConnectionString: notificationHub.outputs.hubConnectionString
notificationHubName: notificationHub.outputs.hubName
}
}

Expand All @@ -94,18 +110,6 @@ module eventgrid 'modules/eventgrid.bicep' = {
}
}

// ── PHASE 2 ─────────────────────────────────────────────────────────
// Notification Hubs Free tier — namespace + hub.
module notificationHub 'modules/notificationhub.bicep' = {
name: 'notification-hub-${env}'
params: {
location: location
namePrefix: namePrefix
env: env
tags: tags
}
}

// ── outputs consumed by GitHub Actions cd-deploy.yml ────────────────
output functionAppName string = functions.outputs.functionAppName
output functionAppHostname string = functions.outputs.defaultHostname
12 changes: 12 additions & 0 deletions infra/modules/functions.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ param cosmosAccountName string
@description('Key Vault name (for @Microsoft.KeyVault references).')
param keyVaultName string

@description('Notification Hub full SAS connection string (DeviceApi + PushDelivery).')
@secure()
param notificationHubConnectionString string

@description('Notification Hub name (DeviceApi + PushDelivery).')
param notificationHubName string

var storageName = toLower('st${namePrefix}${env}${uniqueString(resourceGroup().id)}')
var planName = 'plan-${namePrefix}-${env}'
var appInsightsName = 'appi-${namePrefix}-${env}'
Expand Down Expand Up @@ -119,6 +126,11 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = {
{ name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString }
{ name: 'COSMOS_ACCOUNT_NAME', value: cosmosAccountName }
{ name: 'KEY_VAULT_NAME', value: keyVaultName }
// DeviceApi (Notify.DeviceApi/DeviceApiOptions.cs) reads these via
// ConfigureFunctionsWorkerDefaults binding. PushDelivery will consume
// the same pair when it lands.
{ name: 'NotificationHubConnectionString', value: notificationHubConnectionString }
{ name: 'NotificationHubName', value: notificationHubName }
]
}
}
Expand Down
12 changes: 12 additions & 0 deletions infra/modules/notificationhub.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,17 @@ resource hub 'Microsoft.NotificationHubs/namespaces/notificationHubs@2023-09-01'
properties: {}
}

// Default authorization rule with Manage permissions — created automatically
// alongside every NH. We listKeys against it to surface the connection string
// to consumers (DeviceApi for CreateOrUpdateInstallation, PushDelivery for
// SendNotification). Treat the output as sensitive — only flow it to other
// modules' app settings, never log it.
resource defaultRule 'Microsoft.NotificationHubs/namespaces/notificationHubs/AuthorizationRules@2023-09-01' existing = {
parent: hub
name: 'DefaultFullSharedAccessSignature'
}

output namespaceName string = namespace.name
output hubName string = hub.name
@secure()
output hubConnectionString string = defaultRule.listKeys().primaryConnectionString
14 changes: 14 additions & 0 deletions src/Notify.DeviceApi/DeviceApiOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Notify.DeviceApi;

// Strongly-typed bindings for the app settings DeviceApi reads at startup.
// Configured by infra/modules/functions.bicep; local.settings.json mirrors
// it for local dev. NotificationHubConnectionString is a Key Vault reference
// at deploy time (full SAS — DeviceApi needs Manage permissions to
// CreateOrUpdate Installations).
public sealed record DeviceApiOptions
{
public required string NotificationHubConnectionString { get; init; }
public required string NotificationHubName { get; init; }

public const int MaxRequestBodyBytes = 4 * 1024;
}
17 changes: 17 additions & 0 deletions src/Notify.DeviceApi/Devices/DeviceRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;

namespace Notify.DeviceApi.Devices;

// Registration request from the iOS app. The platform field is fixed to
// "apns" for v1 — Android can land later as a sibling enum value.
public sealed record DeviceRegistration
{
[JsonPropertyName("deviceToken")]
public required string DeviceToken { get; init; }

[JsonPropertyName("platform")]
public required string Platform { get; init; }

[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
62 changes: 62 additions & 0 deletions src/Notify.DeviceApi/Devices/DeviceRegistrationValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Notify.Shared.Validation;

namespace Notify.DeviceApi.Devices;

// Lightweight validator — the heavy contract validation lives in Notify.Shared
// (NotifyCreatedV1Validator). This keeps the DeviceApi self-contained until
// the iOS Codable side wants to share validation in Phase 3.
public static class DeviceRegistrationValidator
{
public const int MaxTagCount = 50;
public const int MaxTagLength = 120;
// APNs device tokens are 32 bytes (64 hex chars) historically; newer
// device tokens (iOS 13+) can be 100+ bytes. Cap at 256 hex chars to leave
// room without inviting abuse.
public const int MinTokenLength = 64;
public const int MaxTokenLength = 256;

public static ValidationResult Validate(DeviceRegistration r)
{
var failures = new List<ValidationFailure>();

if (string.IsNullOrWhiteSpace(r.DeviceToken))
failures.Add(new ValidationFailure(nameof(r.DeviceToken), "required"));
else if (r.DeviceToken.Length < MinTokenLength || r.DeviceToken.Length > MaxTokenLength)
failures.Add(new ValidationFailure(nameof(r.DeviceToken), $"length must be {MinTokenLength}..{MaxTokenLength} chars"));
else if (!IsHex(r.DeviceToken))
failures.Add(new ValidationFailure(nameof(r.DeviceToken), "must be hex"));

if (string.IsNullOrWhiteSpace(r.Platform))
failures.Add(new ValidationFailure(nameof(r.Platform), "required"));
else if (!string.Equals(r.Platform, "apns", StringComparison.OrdinalIgnoreCase))
failures.Add(new ValidationFailure(nameof(r.Platform), "only 'apns' is supported in v1"));

if (r.Tags is { Count: > MaxTagCount })
failures.Add(new ValidationFailure(nameof(r.Tags), $"at most {MaxTagCount} tags"));

if (r.Tags is not null)
{
for (var i = 0; i < r.Tags.Count; i++)
{
var tag = r.Tags[i];
if (string.IsNullOrWhiteSpace(tag))
failures.Add(new ValidationFailure($"{nameof(r.Tags)}[{i}]", "empty"));
else if (tag.Length > MaxTagLength)
failures.Add(new ValidationFailure($"{nameof(r.Tags)}[{i}]", $"length must be ≤ {MaxTagLength} chars"));
}
}

return new ValidationResult(failures);
}

private static bool IsHex(string s)
{
for (var i = 0; i < s.Length; i++)
{
var c = s[i];
var ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!ok) return false;
}
return true;
}
}
60 changes: 60 additions & 0 deletions src/Notify.DeviceApi/Devices/RegisterHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Azure.NotificationHubs;
using Notify.Shared.Json;
using Notify.Shared.Validation;

namespace Notify.DeviceApi.Devices;

// Pure registration logic; the Function class is a thin HTTP shim around it.
// NH calls go through INotificationHub so unit tests don't need a real hub.
public sealed class RegisterHandler
{
private readonly INotificationHub _hub;

public RegisterHandler(INotificationHub hub) => _hub = hub;

public async Task<RegisterResult> HandleAsync(Stream body, long? contentLength, CancellationToken ct = default)
{
if (contentLength is > DeviceApiOptions.MaxRequestBodyBytes)
return new RegisterResult.PayloadTooLarge(DeviceApiOptions.MaxRequestBodyBytes);

DeviceRegistration? input;
try
{
input = await JsonSerializer.DeserializeAsync<DeviceRegistration>(body, NotifyJson.Options, ct);
}
catch (JsonException ex)
{
return new RegisterResult.BadRequest(new[] { new ValidationFailure("body", ex.Message) });
}

if (input is null)
return new RegisterResult.BadRequest(new[] { new ValidationFailure("body", "missing") });

var validation = DeviceRegistrationValidator.Validate(input);
if (!validation.IsValid)
return new RegisterResult.BadRequest(validation.Failures);

var installationId = InstallationIdFor(input.DeviceToken);
var installation = new Installation
{
InstallationId = installationId,
Platform = NotificationPlatform.Apns,
PushChannel = input.DeviceToken,
Tags = input.Tags is null ? null : new List<string>(input.Tags),
};

await _hub.UpsertInstallationAsync(installation, ct);
return new RegisterResult.Accepted(installationId);
}

// Deterministic installation id keyed on the device token so re-registration
// is idempotent (CreateOrUpdate semantics on NH).
public static string InstallationIdFor(string deviceToken)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(deviceToken));
return Convert.ToHexStringLower(hash);
}
}
12 changes: 12 additions & 0 deletions src/Notify.DeviceApi/Devices/RegisterResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Notify.Shared.Validation;

namespace Notify.DeviceApi.Devices;

// Discriminated result of a single device-registration attempt; the Function
// maps it to the right HTTP status. Mirrors IngestResult in Notify.IngestionApi.
public abstract record RegisterResult
{
public sealed record Accepted(string InstallationId) : RegisterResult;
public sealed record BadRequest(IReadOnlyList<ValidationFailure> Failures) : RegisterResult;
public sealed record PayloadTooLarge(int LimitBytes) : RegisterResult;
}
51 changes: 51 additions & 0 deletions src/Notify.DeviceApi/Functions/RegisterDeviceFunction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Net;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Notify.DeviceApi.Devices;
using Notify.Shared.Json;

namespace Notify.DeviceApi.Functions;

// Thin HTTP shim around RegisterHandler. AuthorizationLevel.Function uses the
// Function App's per-function key — the iOS app fetches it once via TestFlight
// build configuration. Project-key auth (npk_*) is not used here because
// devices are user-owned, not project-scoped.
public sealed class RegisterDeviceFunction
{
private readonly RegisterHandler _handler;
private readonly ILogger<RegisterDeviceFunction> _logger;

public RegisterDeviceFunction(RegisterHandler handler, ILogger<RegisterDeviceFunction> logger)
{
_handler = handler;
_logger = logger;
}

[Function("RegisterDevice")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "v1/devices")]
HttpRequestData req)
{
var contentLength = req.Headers.TryGetValues("content-length", out var clen) && long.TryParse(clen.FirstOrDefault(), out var cl) ? cl : (long?)null;

var result = await _handler.HandleAsync(req.Body, contentLength);

return result switch
{
RegisterResult.Accepted a => await Json(req, HttpStatusCode.Accepted, new { installationId = a.InstallationId }),
RegisterResult.BadRequest b => await Json(req, HttpStatusCode.BadRequest, new { errors = b.Failures }),
RegisterResult.PayloadTooLarge p => await Json(req, HttpStatusCode.RequestEntityTooLarge, new { error = $"payload exceeds {p.LimitBytes} bytes" }),
_ => throw new InvalidOperationException($"Unexpected result type {result.GetType()}"),
};
}

private static async Task<HttpResponseData> Json(HttpRequestData req, HttpStatusCode status, object body)
{
var resp = req.CreateResponse(status);
resp.Headers.Add("content-type", "application/json; charset=utf-8");
await resp.WriteStringAsync(JsonSerializer.Serialize(body, NotifyJson.Options));
return resp;
}
}
20 changes: 20 additions & 0 deletions src/Notify.DeviceApi/INotificationHub.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.Azure.NotificationHubs;

namespace Notify.DeviceApi;

// Seam between the handler and Microsoft.Azure.NotificationHubs.NotificationHubClient
// so unit tests can record installations without standing up a real hub.
public interface INotificationHub
{
Task UpsertInstallationAsync(Installation installation, CancellationToken ct = default);
}

public sealed class NotificationHubAdapter : INotificationHub
{
private readonly NotificationHubClient _client;

public NotificationHubAdapter(NotificationHubClient client) => _client = client;

public Task UpsertInstallationAsync(Installation installation, CancellationToken ct = default)
=> _client.CreateOrUpdateInstallationAsync(installation, ct);
}
41 changes: 41 additions & 0 deletions src/Notify.DeviceApi/Notify.DeviceApi.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>Notify.DeviceApi</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="3.1.0" />
<!-- Pin transitive: 3.1.0 pulls OpenTelemetry.Api 1.15.1 which has GHSA-g94r-2vxg-569j (moderate). -->
<PackageReference Include="OpenTelemetry.Api" Version="1.15.3" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<!-- Pin transitive: NH SDK 4.2.0 pulls Microsoft.Extensions.Caching.Memory 6.0.1
which has GHSA-qj66-m88j-hmgj (high). Latest 9.x patches it. -->
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Notify.Shared\Notify.Shared.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>

</Project>
Loading
Loading