diff --git a/Registration/Auth/ApiKeyAuthFilter.cs b/Registration/Auth/ApiKeyAuthFilter.cs
new file mode 100644
index 0000000..7ed056e
--- /dev/null
+++ b/Registration/Auth/ApiKeyAuthFilter.cs
@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace Registration.Auth;
+
+///
+/// Validates the x-functions-key header on admin endpoints.
+///
+/// In Azure Functions mode, admin endpoints used AuthorizationLevel.Function —
+/// the Functions host validated the key automatically. In ASP.NET Core mode
+/// that host doesn't exist, so this filter replicates the same behaviour.
+///
+/// The same header name (x-functions-key) and the same env var
+/// (Registration__FunctionKey) are used in both modes, so the Management API
+/// and Gateway require zero changes when the host is swapped.
+///
+/// Key configuration (docker-compose .env already has this):
+/// Registration__FunctionKey=mra0B2boC5m36E7CUn-Urhwp7k3t3QvPZKjJvtNVEdVgAzFuuaAyRA==
+///
+/// ── SWAP note ─────────────────────────────────────────────────────────────
+/// This file is only compiled and used in ASP.NET Core mode.
+/// When restoring Azure Functions mode, the [ApiKeyAuth] attributes on admin
+/// endpoints in RegistrationFunctions.cs are replaced by AuthorizationLevel.Function
+/// in the [HttpTrigger] attributes, and this filter becomes unused (but harmless).
+/// ─────────────────────────────────────────────────────────────────────────
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public sealed class ApiKeyAuthAttribute : Attribute, IResourceFilter
+{
+ private const string HeaderName = "x-functions-key";
+
+ public void OnResourceExecuting(ResourceExecutingContext context)
+ {
+ var config = context.HttpContext.RequestServices.GetRequiredService();
+ var expected = config["Registration:FunctionKey"];
+
+ // If no key is configured, block all admin traffic — fail secure.
+ if (string.IsNullOrWhiteSpace(expected))
+ {
+ context.Result = new ObjectResult(new { ok = false, error = "Admin API key not configured on server" })
+ {
+ StatusCode = StatusCodes.Status503ServiceUnavailable
+ };
+ return;
+ }
+
+ if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var provided)
+ || !string.Equals(expected, provided, StringComparison.Ordinal))
+ {
+ context.Result = new UnauthorizedObjectResult(new { ok = false, error = "Invalid or missing API key" });
+ }
+ }
+
+ public void OnResourceExecuted(ResourceExecutedContext context) { }
+}
diff --git a/Registration/Functions/RegistrationFunctions.cs b/Registration/Functions/RegistrationFunctions.cs
index 03da4f7..891539f 100644
--- a/Registration/Functions/RegistrationFunctions.cs
+++ b/Registration/Functions/RegistrationFunctions.cs
@@ -1,12 +1,21 @@
+// ── SWAP note ─────────────────────────────────────────────────────────────
+// Three files change when switching host modes. See Registration.csproj for
+// the full checklist. This file: swap the class declaration and each method
+// signature (marked below). Program.cs and Registration.csproj also change.
+// ─────────────────────────────────────────────────────────────────────────
+
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Azure.Functions.Worker; // ← SWAP: remove for AspNetCore
using Microsoft.Extensions.Logging;
+using Registration.Auth;
using Registration.Data;
using System.Security.Claims;
using System.Text.Json;
+// ── SWAP: Azure Functions — uncomment this using when restoring Functions mode
+// using Microsoft.Azure.Functions.Worker;
+
namespace Registration.Functions;
///
@@ -17,10 +26,10 @@ namespace Registration.Functions;
/// entraSubjectId is extracted from the validated
/// token — the client never supplies it.
///
-/// GET /api/registration/pending — Admin; requires internal API key.
-/// GET /api/registration/item/{id} — Admin; requires internal API key.
-/// POST /api/registration/action/{id}/reject — Admin; requires internal API key.
-/// POST /api/registration/action/{id}/complete — Admin; requires internal API key.
+/// GET /api/registration/pending — Admin; x-functions-key header required.
+/// GET /api/registration/item/{id} — Admin; x-functions-key header required.
+/// POST /api/registration/action/{id}/reject — Admin; x-functions-key header required.
+/// POST /api/registration/action/{id}/complete — Admin; x-functions-key header required.
/// GET /api/health — Anonymous.
///
/// ═══════════════════════════════════════════════════════
@@ -29,20 +38,23 @@ namespace Registration.Functions;
/// 2. Program.cs
/// 3. Registration.csproj
///
-/// To switch to ASP.NET Core:
-/// a) In this file: swap class declaration and method signatures (marked below)
-/// b) In Program.cs: comment Azure Functions block, uncomment AspNetCore block
-/// c) In Registration.csproj: swap ItemGroup (marked in that file)
-/// d) In authConfig.js (client): update API_BASE_URL, remove API_FUNCTION_KEY
+/// To switch back to Azure Functions:
+/// a) Swap class declaration (marked below)
+/// b) Swap each method signature (marked below)
+/// c) Uncomment [Function(...)] and [HttpTrigger(...)] attributes
+/// d) Re-add req parameter and remove [ApiKeyAuth] on admin endpoints
+/// (Functions mode uses AuthorizationLevel.Function instead)
+/// e) In authConfig.js: update API_BASE_URL to Function App URL,
+/// set API_FUNCTION_KEY from Azure Portal → App Keys → default
/// ═══════════════════════════════════════════════════════
///
-// ── SWAP: Azure Functions class declaration ───────────────────────────────
-public class RegistrationFunctions
-// ── SWAP: AspNetCore class declaration ────────────────────────────────────
-// [ApiController]
-// [Route("api")]
-// public class RegistrationFunctions : ControllerBase
+// ── SWAP: ASP.NET Core class declaration ◄ ACTIVE ───────────────────────
+[ApiController]
+[Route("api")]
+public class RegistrationFunctions : ControllerBase
+// ── SWAP: Azure Functions class declaration ◄ INACTIVE — uncomment to restore
+// public class RegistrationFunctions
// ─────────────────────────────────────────────────────────────────────────
{
private readonly IRegistrationDataService _data;
@@ -58,7 +70,7 @@ public class RegistrationFunctions
public RegistrationFunctions(IRegistrationDataService data, ILogger log)
{
_data = data;
- _log = log;
+ _log = log;
}
// ── Helpers ──────────────────────────────────────────────────────────
@@ -66,6 +78,7 @@ public class RegistrationFunctions
///
/// Extract the Entra Object ID from a validated CIAM JWT.
/// The OID claim is stable across sessions and providers (Google, Apple, Microsoft).
+ /// CIAM tenant: PositiveSpendClients.ciamlogin.com / cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b
///
private static string? GetEntraSubjectId(ClaimsPrincipal user) =>
user.FindFirst("oid")?.Value
@@ -74,20 +87,21 @@ public class RegistrationFunctions
// ── Public: Register ─────────────────────────────────────────────────
- // ── SWAP: Azure Functions ─────────────────────────────────────────────
- [Function("Register")]
+ // ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
+ [HttpPost("registration/register")]
[Authorize]
- public async Task Register(
- [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req,
- CancellationToken ct)
- // ── SWAP: AspNetCore ──────────────────────────────────────────────────
- // [HttpPost("registration/register")]
+ public async Task Register(CancellationToken ct)
+ // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
+ // [Function("Register")]
// [Authorize]
- // public async Task Register(CancellationToken ct)
+ // public async Task Register(
+ // [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req,
+ // CancellationToken ct)
// ─────────────────────────────────────────────────────────────────────
{
- // AspNetCore only — uncomment when in AspNetCore mode:
- // var req = HttpContext.Request;
+ // ── SWAP: ASP.NET Core uses HttpContext.Request ───────────────────
+ var req = HttpContext.Request;
+ // ── SWAP: Azure Functions — req is passed as parameter, remove above line
var entraSubjectId = GetEntraSubjectId(req.HttpContext.User);
@@ -110,6 +124,7 @@ public class RegistrationFunctions
if (request == null || string.IsNullOrWhiteSpace(request.BusinessName))
return new BadRequestObjectResult(new { ok = false, error = "businessName is required" });
+ // Stamp from the validated token — never trust the request body for this.
request.EntraSubjectId = entraSubjectId;
_log.LogInformation("[Registration] POST register: {Name} by entra={EntraId}",
@@ -125,14 +140,15 @@ public class RegistrationFunctions
// ── Admin: List pending ───────────────────────────────────────────────
- // ── SWAP: Azure Functions ─────────────────────────────────────────────
- [Function("GetPending")]
- public async Task GetPending(
- [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/pending")] HttpRequest req,
- CancellationToken ct)
- // ── SWAP: AspNetCore ──────────────────────────────────────────────────
- // [HttpGet("registration/pending")]
- // public async Task GetPending(CancellationToken ct)
+ // ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
+ [HttpGet("registration/pending")]
+ [ApiKeyAuth]
+ public async Task GetPending(CancellationToken ct)
+ // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
+ // [Function("GetPending")]
+ // public async Task GetPending(
+ // [HttpTrigger(AuthorizationLevel.Function, "get", Route = "registration/pending")] HttpRequest req,
+ // CancellationToken ct)
// ─────────────────────────────────────────────────────────────────────
{
_log.LogInformation("[Registration] GET pending");
@@ -142,15 +158,16 @@ public class RegistrationFunctions
// ── Admin: Get by ID ─────────────────────────────────────────────────
- // ── SWAP: Azure Functions ─────────────────────────────────────────────
- [Function("GetById")]
- public async Task GetById(
- [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/item/{registrationId}")] HttpRequest req,
- string registrationId,
- CancellationToken ct)
- // ── SWAP: AspNetCore ──────────────────────────────────────────────────
- // [HttpGet("registration/item/{registrationId}")]
- // public async Task GetById(string registrationId, CancellationToken ct)
+ // ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
+ [HttpGet("registration/item/{registrationId}")]
+ [ApiKeyAuth]
+ public async Task GetById(string registrationId, CancellationToken ct)
+ // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
+ // [Function("GetById")]
+ // public async Task GetById(
+ // [HttpTrigger(AuthorizationLevel.Function, "get", Route = "registration/item/{registrationId}")] HttpRequest req,
+ // string registrationId,
+ // CancellationToken ct)
// ─────────────────────────────────────────────────────────────────────
{
_log.LogInformation("[Registration] GET {Id}", registrationId);
@@ -164,19 +181,21 @@ public class RegistrationFunctions
// ── Admin: Reject ────────────────────────────────────────────────────
- // ── SWAP: Azure Functions ─────────────────────────────────────────────
- [Function("Reject")]
- public async Task Reject(
- [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req,
- string registrationId,
- CancellationToken ct)
- // ── SWAP: AspNetCore ──────────────────────────────────────────────────
- // [HttpPost("registration/action/{registrationId}/reject")]
- // public async Task Reject(string registrationId, CancellationToken ct)
+ // ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
+ [HttpPost("registration/action/{registrationId}/reject")]
+ [ApiKeyAuth]
+ public async Task Reject(string registrationId, CancellationToken ct)
+ // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
+ // [Function("Reject")]
+ // public async Task Reject(
+ // [HttpTrigger(AuthorizationLevel.Function, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req,
+ // string registrationId,
+ // CancellationToken ct)
// ─────────────────────────────────────────────────────────────────────
{
- // AspNetCore only — uncomment when in AspNetCore mode:
- // var req = HttpContext.Request;
+ // ── SWAP: ASP.NET Core ────────────────────────────────────────────
+ var req = HttpContext.Request;
+ // ── SWAP: Azure Functions — req is passed as parameter, remove above line
RejectBody? body = null;
try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); }
@@ -195,19 +214,21 @@ public class RegistrationFunctions
// ── Admin: Complete ───────────────────────────────────────────────────
- // ── SWAP: Azure Functions ─────────────────────────────────────────────
- [Function("Complete")]
- public async Task Complete(
- [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req,
- string registrationId,
- CancellationToken ct)
- // ── SWAP: AspNetCore ──────────────────────────────────────────────────
- // [HttpPost("registration/action/{registrationId}/complete")]
- // public async Task Complete(string registrationId, CancellationToken ct)
+ // ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
+ [HttpPost("registration/action/{registrationId}/complete")]
+ [ApiKeyAuth]
+ public async Task Complete(string registrationId, CancellationToken ct)
+ // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
+ // [Function("Complete")]
+ // public async Task Complete(
+ // [HttpTrigger(AuthorizationLevel.Function, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req,
+ // string registrationId,
+ // CancellationToken ct)
// ─────────────────────────────────────────────────────────────────────
{
- // AspNetCore only — uncomment when in AspNetCore mode:
- // var req = HttpContext.Request;
+ // ── SWAP: ASP.NET Core ────────────────────────────────────────────
+ var req = HttpContext.Request;
+ // ── SWAP: Azure Functions — req is passed as parameter, remove above line
CompleteBody? body = null;
try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); }
@@ -226,20 +247,21 @@ public class RegistrationFunctions
// ── Health ───────────────────────────────────────────────────────────
- // ── SWAP: Azure Functions ─────────────────────────────────────────────
- [Function("Health")]
- public IActionResult Health(
- [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req)
- // ── SWAP: AspNetCore ──────────────────────────────────────────────────
- // [HttpGet("health")]
- // public IActionResult Health()
+ // ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
+ [HttpGet("health")]
+ [AllowAnonymous]
+ public IActionResult Health()
+ // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
+ // [Function("Health")]
+ // public IActionResult Health(
+ // [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req)
// ─────────────────────────────────────────────────────────────────────
{
return new OkObjectResult(new
{
- ok = true,
- service = "registration",
- mode = _data is Registration.Mock.MockDataService ? "mock" : "database",
+ ok = true,
+ service = "registration",
+ mode = _data is Registration.Mock.MockDataService ? "mock" : "database",
timestamp = DateTime.UtcNow
});
}
@@ -249,11 +271,11 @@ public class RegistrationFunctions
internal sealed class RejectBody
{
- public string? Reason { get; set; }
+ public string? Reason { get; set; }
public string? RejectedBy { get; set; }
}
internal sealed class CompleteBody
{
public string? PlatformClientId { get; set; }
-}
\ No newline at end of file
+}
diff --git a/Registration/Program.cs b/Registration/Program.cs
index 93dc26b..4acd695 100644
--- a/Registration/Program.cs
+++ b/Registration/Program.cs
@@ -1,29 +1,25 @@
-using Microsoft.Azure.Functions.Worker; // ← SWAP: remove for AspNetCore
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Identity.Web;
-using Registration.Data;
-using Registration.Mock;
-
// ─────────────────────────────────────────────────────────────────────────────
// Shared service registrations — identical in both hosting modes.
// Never changes regardless of which block is active below.
// ─────────────────────────────────────────────────────────────────────────────
+using Microsoft.Identity.Web;
+using Registration.Data;
+
void ConfigureServices(IServiceCollection services, IConfiguration config)
{
// JWT authentication — Entra External ID (CIAM)
- // Validates Bearer tokens issued by usimclients.ciamlogin.com.
- // AzureAd config comes from local.settings.json (Functions) or
- // appsettings.json / environment variables (AspNetCore).
+ // Validates Bearer tokens issued by PositiveSpendClients.ciamlogin.com.
+ // Config keys: AzureAd:Instance / TenantId / ClientId / Audience
+ // In ASP.NET Core mode: appsettings.json + env vars (AzureAd__*)
+ // In Functions mode: local.settings.json AzureAd section (dev only)
services.AddAuthentication()
.AddMicrosoftIdentityWebApi(config.GetSection("AzureAd"));
services.AddAuthorization();
- // Data layer — swap between MockDataService (no DB) and SqlDataService (dbRegistration).
+ // Data layer — swap here to flip between mock (no DB) and real DB.
// MockDataService: no connection string required, seeds 4 test applicants.
- // SqlDataService: requires ConnectionStrings:Sql pointed at dbRegistration.
+ // SqlDataService: requires ConnectionStrings:Sql → dbRegistration on 10.10.99.212
// Calls dbo.spRegistration with @action/@rqst/@resp OUTPUT pattern.
services.AddSingleton();
services.AddSingleton();
@@ -32,45 +28,51 @@ void ConfigureServices(IServiceCollection services, IConfiguration config)
}
// ═════════════════════════════════════════════════════════════════════════════
-// SWAP: Azure Functions host
+// SWAP: ASP.NET Core host ◄ ACTIVE
+// Running in docker-compose as registration:8080 behind nginx / Gateway.
+// Dockerfile: mcr.microsoft.com/dotnet/aspnet:8.0 ← matches this mode.
+// ═════════════════════════════════════════════════════════════════════════════
+var builder = WebApplication.CreateBuilder(args);
+
+ConfigureServices(builder.Services, builder.Configuration);
+builder.Services.AddControllers();
+
+// CORS — reads CORS:AllowedOrigins from appsettings.json or env var CORS__AllowedOrigins.
+// Comma-separated list. Matches the value already in docker-compose .env:
+// CORS__AllowedOrigins=https://client.positivespend.com,https://portal.positivespend.com,...
+builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
+ policy.WithOrigins(
+ (builder.Configuration["CORS:AllowedOrigins"] ?? "")
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ )
+ .AllowAnyHeader()
+ .AllowAnyMethod()));
+
+var app = builder.Build();
+
+app.UseCors();
+app.UseAuthentication();
+app.UseAuthorization();
+app.MapControllers();
+
+await app.RunAsync();
+
+// ═════════════════════════════════════════════════════════════════════════════
+// SWAP: Azure Functions host ◄ INACTIVE — uncomment to restore
// Active when deployed to Azure Functions or running via: func start
-// ═════════════════════════════════════════════════════════════════════════════
-var host = new HostBuilder()
- .ConfigureFunctionsWebApplication()
- .ConfigureServices((ctx, services) =>
- {
- services.AddApplicationInsightsTelemetryWorkerService();
- services.ConfigureFunctionsApplicationInsights();
- ConfigureServices(services, ctx.Configuration);
- })
- .Build();
-
-host.Run();
-
-// ═════════════════════════════════════════════════════════════════════════════
-// SWAP: ASP.NET Core host
-// Active when running in podman-compose or dotnet run (self-hosted).
// Also update RegistrationFunctions.cs and Registration.csproj.
// ═════════════════════════════════════════════════════════════════════════════
-// var builder = WebApplication.CreateBuilder(args);
-//
-// ConfigureServices(builder.Services, builder.Configuration);
-// builder.Services.AddControllers();
-//
-// // CORS — replace with actual client origin in production
-// builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
-// policy.WithOrigins(
-// builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:3001"
-// )
-// .AllowAnyHeader()
-// .AllowAnyMethod()));
-//
-// var app = builder.Build();
-//
-// app.UseCors();
-// app.UseAuthentication();
-// app.UseAuthorization();
-// app.MapControllers();
-//
-// await app.RunAsync();
-// ─────────────────────────────────────────────────────────────────────────────
\ No newline at end of file
+// using Microsoft.Azure.Functions.Worker;
+//
+// var host = new HostBuilder()
+// .ConfigureFunctionsWebApplication()
+// .ConfigureServices((ctx, services) =>
+// {
+// services.AddApplicationInsightsTelemetryWorkerService();
+// services.ConfigureFunctionsApplicationInsights();
+// ConfigureServices(services, ctx.Configuration);
+// })
+// .Build();
+//
+// host.Run();
+// ─────────────────────────────────────────────────────────────────────────────
diff --git a/Registration/README.md b/Registration/README.md
index 37701da..fc803c3 100644
--- a/Registration/README.md
+++ b/Registration/README.md
@@ -1,91 +1,126 @@
-# Registration Function
+# Registration Service
-Azure Function (isolated worker, .NET 8) for managing prospect registration in AdPlatform.
+ASP.NET Core (.NET 8) service for managing prospect registration in AdPlatform.
+Self-hosted via docker-compose as `registration:8080` behind nginx / Gateway.
## Architecture
```
-Prospect → Registration Function → dbRegistration (future)
-Admin Panel → Management API → Registration Function (proxy)
- → spClientManagement (approve → dbAdPlatform)
+Client SPA (client.positivespend.com)
+ └─► Gateway (portal.positivespend.com)
+ └─► Registration API (regapi.positivespend.com → registration:8080)
+ ├─► dbRegistration (10.10.99.212 — Registrations table, spRegistration)
+ └─► CIAM (PositiveSpendClients.ciamlogin.com — token validation)
+
+Management API (mgmt.positivespend.com)
+ └─► Registration API (admin endpoints, x-functions-key auth)
+ └─► dbRegistration
```
-Management validates admin sessions, then proxies registration calls to this Function.
-The Function never touches `dbAdPlatform`. Management never touches `dbRegistration`.
+The Registration service never touches `dbAdPlatform`. Management never touches `dbRegistration`.
+Approval provisioning into `dbAdPlatform` is the Management API's responsibility, called after
+the `complete` action marks a registration as Approved.
## Endpoints
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
-| GET | `/api/registration/pending` | Function Key | List pending applicants |
-| GET | `/api/registration/{id}` | Function Key | Get single applicant |
-| POST | `/api/registration/register` | Function Key | New prospect signup |
-| POST | `/api/registration/{id}/reject` | Function Key | Reject applicant |
-| POST | `/api/registration/{id}/complete` | Function Key | Mark approved (called after platform client created) |
-| GET | `/api/registration/health` | Anonymous | Health check |
+| `POST` | `/api/registration/register` | Bearer (CIAM JWT) | New prospect signup |
+| `GET` | `/api/registration/pending` | x-functions-key | List pending applicants |
+| `GET` | `/api/registration/item/{id}` | x-functions-key | Get single applicant |
+| `POST` | `/api/registration/action/{id}/reject` | x-functions-key | Reject applicant |
+| `POST` | `/api/registration/action/{id}/complete` | x-functions-key | Mark approved |
+| `GET` | `/api/health` | Anonymous | Health check |
-## Mock Mode (Current)
+`entraSubjectId` is **never** read from the request body on `/register` — it is extracted
+from the validated CIAM Bearer token (OID claim). The client cannot spoof it.
-Starts with 4 realistic test applicants in memory. State persists within a Function host
-lifecycle and resets on cold start. No database required.
+## Authentication
+
+**Client-facing endpoint** (`/register`): Bearer token issued by
+`PositiveSpendClients.ciamlogin.com` (tenant `cbf8b7d7`). Validated by
+`Microsoft.Identity.Web` against the CIAM tenant. The Client SPA acquires this
+token via MSAL after the user signs in with Google, Apple, or Microsoft.
+
+**Admin endpoints** (`pending` / `item` / `reject` / `complete`): `x-functions-key` header
+validated by `ApiKeyAuthFilter`. Key must match `Registration__FunctionKey` env var.
+These endpoints are called by the Management API, not the browser.
+
+## CIAM Tenant Reference
+
+| Field | Value |
+|-------|-------|
+| Tenant | Positive Spend Clients |
+| Tenant ID | `cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b` |
+| Client SPA App ID | `c426967f-bfcc-46af-b4e5-d69dc01cbf75` |
+| Authority | `https://PositiveSpendClients.ciamlogin.com/cbf8b7d7.../` |
+
+This is the **client-facing CIAM tenant** — separate from the internal `positivespend.com`
+org tenant (`f56a3c51`) used by Management and the Tech/Admin consoles.
+
+## Mock Mode
+
+Switch in `Program.cs` to run without a database (seeds 4 test applicants in memory):
-To switch to mock mode, in `Program.cs`:
```csharp
services.AddSingleton();
```
-## Database Mode (Future)
+State resets on container restart — by design.
-When `dbRegistration` is ready:
+## Database Setup
-1. Create the database and run the `spRegistration` stored proc migration
-2. Set `ConnectionStrings:Sql` to the registration database connection string
-3. In `Program.cs`, swap DI registration:
-```csharp
-services.AddSingleton();
-services.AddSingleton();
-```
+Run `dbo.spRegistration.sql` against `dbRegistration` on `10.10.99.212` once.
+It is idempotent (`CREATE OR ALTER PROCEDURE`, `IF OBJECT_ID ... IS NULL` guard on the table).
-The `SqlDataService` calls `dbo.spRegistration` with the standard `@action/@rqst/@resp OUTPUT`
-pattern used across all AdPlatform services.
-
-## Local Development
+## docker-compose Environment Variables
```bash
-# Requires Azure Functions Core Tools
+# SQL Server
+ConnectionStrings__Sql=Server=10.10.99.212;Database=dbRegistration;User Id=appAdPlatformReg;Password=...;TrustServerCertificate=True;
+
+# CIAM — already correct in .env
+AzureAd__Instance=https://PositiveSpendClients.ciamlogin.com/
+AzureAd__TenantId=cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b
+AzureAd__ClientId=c426967f-bfcc-46af-b4e5-d69dc01cbf75
+
+# CORS — already in .env
+CORS__AllowedOrigins=https://client.positivespend.com,https://portal.positivespend.com,...
+
+# Admin key — already in .env
+Registration__FunctionKey=mra0B2boC5m36E7CUn-Urhwp7k3t3QvPZKjJvtNVEdVgAzFuuaAyRA==
+```
+
+## Health Check
+
+```bash
+curl https://regapi.positivespend.com/api/health
+# {"ok":true,"service":"registration","mode":"database","timestamp":"..."}
+```
+
+## SSL
+
+```bash
+sudo certbot --nginx -d regapi.positivespend.com
+```
+
+---
+
+## Host Swap — Restore Azure Functions
+
+Three files change. Everything else (Data layer, Mock layer, models, SQL) is identical.
+
+| File | Change |
+|------|--------|
+| `Registration.csproj` | Comment ASP.NET Core `ItemGroup`, uncomment Functions `ItemGroup`, restore `v4`, change `Sdk` to `Microsoft.NET.Sdk` |
+| `Program.cs` | Comment ASP.NET Core block, uncomment Functions `HostBuilder` block |
+| `Functions/RegistrationFunctions.cs` | Swap class declaration, swap each method signature (all marked `◄ INACTIVE`) |
+
+Client changes when restoring Functions:
+- `authConfig.js`: set `API_BASE_URL` to the Azure Function App URL, set `API_FUNCTION_KEY` from Azure Portal → App Keys → default
+
+Run locally in Functions mode:
+```bash
func start
+curl http://localhost:7071/api/health
```
-
-Test with:
-```bash
-curl http://localhost:7071/api/registration/health
-curl http://localhost:7071/api/registration/pending
-```
-
-## Deployment
-
-Deploy as an Azure Function App (Consumption or Flex Consumption plan).
-
-After deployment:
-1. Copy the Function Key from Azure Portal → Function App → App Keys
-2. Set in Management API config:
- - `Registration:BaseUrl` = `https://your-function-app.azurewebsites.net/api`
- - `Registration:FunctionKey` = ``
-
-These can be set as Azure Container App environment variables:
-```
-Registration__BaseUrl=https://your-function-app.azurewebsites.net/api
-Registration__FunctionKey=
-```
-
-## Mock Applicants
-
-The mock data includes 4 test applicants representing the target market
-(small businesses with low ad spend thresholds):
-
-| Business | Category | Payment Verified | Days Waiting |
-|----------|----------|-----------------|-------------|
-| Bella's Boutique | Retail | Yes | 3 |
-| Pacific Coast Plumbing | Home Services | Yes | 1 |
-| Sunrise Dental Group | Healthcare | No | ~0.25 |
-| FreshBite Meal Prep | Food & Beverage | Yes | ~0.08 |
diff --git a/Registration/Registration.csproj b/Registration/Registration.csproj
index 6d4a407..7c477ff 100644
--- a/Registration/Registration.csproj
+++ b/Registration/Registration.csproj
@@ -1,50 +1,54 @@
-
+
net8.0
- Exe
enable
enable
Registration
-
- v4
+
+
+
-
-
-
-
-
-
-
@@ -56,4 +60,4 @@
-
\ No newline at end of file
+
diff --git a/Registration/appsettings.json b/Registration/appsettings.json
new file mode 100644
index 0000000..9edeee2
--- /dev/null
+++ b/Registration/appsettings.json
@@ -0,0 +1,56 @@
+{
+ // ── Connection Strings ───────────────────────────────────────────────────
+ // Override via docker-compose env var:
+ // ConnectionStrings__Sql=Server=10.10.99.212;Database=dbRegistration;...
+ "ConnectionStrings": {
+ "Sql": "Server=10.10.99.212;Database=dbRegistration;User Id=appAdPlatformReg;Password=CHANGE_ME;TrustServerCertificate=True;"
+ },
+
+ // ── Entra External ID (CIAM) ─────────────────────────────────────────────
+ // Tenant: Positive Spend Clients (cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b)
+ // SPA App: AdPlatform Client SPA (c426967f-bfcc-46af-b4e5-d69dc01cbf75)
+ //
+ // This is the CLIENT-facing CIAM tenant — NOT the internal positivespend.com
+ // org tenant used by the Management/Admin console (f56a3c51).
+ //
+ // Tokens are issued by PositiveSpendClients.ciamlogin.com.
+ // Microsoft.Identity.Web validates issuer, audience, and signature automatically.
+ //
+ // Override via env vars (docker-compose .env):
+ // AzureAd__Instance=https://PositiveSpendClients.ciamlogin.com/
+ // AzureAd__TenantId=cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b
+ // AzureAd__ClientId=c426967f-bfcc-46af-b4e5-d69dc01cbf75
+ "AzureAd": {
+ "Instance": "https://PositiveSpendClients.ciamlogin.com/",
+ "TenantId": "cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b",
+ "ClientId": "c426967f-bfcc-46af-b4e5-d69dc01cbf75",
+ "Audience": "c426967f-bfcc-46af-b4e5-d69dc01cbf75"
+ },
+
+ // ── CORS ─────────────────────────────────────────────────────────────────
+ // Comma-separated allowed origins.
+ // Override via env var: CORS__AllowedOrigins (already in docker-compose .env)
+ "CORS": {
+ "AllowedOrigins": "https://client.positivespend.com,https://portal.positivespend.com"
+ },
+
+ // ── Admin API Key ────────────────────────────────────────────────────────
+ // Used by ApiKeyAuthFilter on admin endpoints (pending / item / reject / complete).
+ // Replaces Azure Functions AuthorizationLevel.Function in self-hosted mode.
+ // Must match Registration__FunctionKey in docker-compose .env.
+ // Override via env var: Registration__FunctionKey
+ "Registration": {
+ "FunctionKey": ""
+ },
+
+ // ── Logging ──────────────────────────────────────────────────────────────
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.Identity": "Warning"
+ }
+ },
+
+ "AllowedHosts": "*"
+}