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
8 changes: 4 additions & 4 deletions src/Abblix.SharedSignals.MinimalApi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ builder.Services
});

var app = builder.Build();
app.MapSsfTransmitterEndpoints("/ssf").RequireAuthorization("ssf-receivers");
app.MapSsfTransmitterEndpoints().RequireAuthorization("ssf-receivers");
```

One call maps the whole management surface under the prefix - streams, status, subjects, verification, poll delivery - plus the configuration document at the well-known address the issuer resolves to.
One call maps the whole management surface under `SsfEndpointOptions.ManagementPrefix` (`/ssf` by default) - streams, status, subjects, verification, poll delivery - plus the configuration document at the well-known address the issuer resolves to. Every route comes from the options, so one object states the whole topology.

The well-known endpoint stays outside the returned group on purpose: discovery must answer before any receiver has credentials, so the authorization you attach to the group does not cover it. What it serves is public metadata - issuer, JWKS location, endpoint addresses, supported delivery methods and authorization schemes - and nothing stream- or receiver-specific; poll delivery sits inside the group, which is where SSF 1.0 Section 7.1.1 wants it.

A gateway-fronted deployment adjusts this without moving the protocol address: `MapSsfTransmitterEndpoints(prefix, mapWellKnownConfiguration: false)` leaves the canonical route to the gateway or CDN in front, and `MapSsfConfigurationDocument(advertisedPrefix, pattern)` serves the same document on an internal route a rewriting proxy maps the canonical address onto - advertising the external prefix, whatever was mapped internally. The external address never moves, because receivers derive it from the issuer.
A gateway-fronted deployment adjusts this in the same options object, without moving the protocol address: `MapWellKnownConfiguration = false` leaves the canonical route to the gateway or CDN in front, `ConfigurationDocumentRoute` names the internal route a rewriting proxy maps the canonical address onto (served by `MapSsfConfigurationDocument()`), and `AdvertisedPrefix` is what the document advertises - the external prefix, whatever `ManagementPrefix` mapped internally. The external address never moves, because receivers derive it from the issuer.

Receivers are told apart by identity: the endpoints read it from the authenticated principal (the `sub` claim, then the identity name), and `SsfEndpointOptions.ReceiverIdSelector` replaces that mapping when the host's authentication carries the identity elsewhere.

Expand All @@ -43,7 +43,7 @@ What one call maps, relative to the prefix:
| `/verify` | POST | verification request, Section 8.1.4 |
| `/poll/{streamId}` | POST | poll delivery, RFC 8936 |

The configuration document at `/.well-known/ssf-configuration` advertises the five management addresses from the very constants that map them, so those cannot drift; the well-known path itself follows the specification, not the prefix, because that fixed address is how a receiver holding only the issuer URI finds everything else. The poll address is the one exception: it travels per stream and comes from your `PollEndpointFactory`, so keep that factory aligned with the prefix you pass here.
The configuration document at `/.well-known/ssf-configuration` advertises the five management addresses from the very constants that map them, so those cannot drift; the well-known path itself follows the specification, not the prefix, because that fixed address is how a receiver holding only the issuer URI finds everything else. The poll address is the one exception: it travels per stream and comes from your `PollEndpointFactory`, so keep that factory aligned with the advertised prefix.

## Receiver

Expand Down
35 changes: 35 additions & 0 deletions src/Abblix.SharedSignals.MinimalApi/SsfEndpointOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,41 @@ public sealed record SsfEndpointOptions
/// </summary>
public Func<HttpContext, string?> ReceiverIdSelector { get; init; } = DefaultReceiverId;

/// <summary>
/// The route prefix the management surface is mapped under. Behind a rewriting proxy this
/// is the INTERNAL prefix; what the configuration document advertises is
/// <see cref="AdvertisedPrefix"/>.
/// </summary>
public PathString ManagementPrefix { get; init; } = "/ssf";

/// <summary>
/// The management prefix the configuration document advertises, as the outside world
/// reaches it; unset advertises <see cref="ManagementPrefix"/>. Set it when a proxy in
/// front rewrites paths, so the document names the external addresses while the routes
/// stay mapped on the internal ones.
/// </summary>
public PathString AdvertisedPrefix { get; init; }

/// <summary>
/// Whether <see cref="SsfEndpointRouteBuilderExtensions.MapSsfTransmitterEndpoints"/> maps
/// the configuration document at the canonical well-known address. True by default; false
/// is for a host whose gateway or CDN answers that address itself. The address is fixed by
/// SSF 1.0 Section 7.2 and receivers derive it from the issuer, so this flag only
/// suppresses the route, never moves it - a host that must serve the document on another
/// internal path pairs it with <see cref="ConfigurationDocumentRoute"/> and
/// <see cref="SsfEndpointRouteBuilderExtensions.MapSsfConfigurationDocument"/>.
/// </summary>
public bool MapWellKnownConfiguration { get; init; } = true;

/// <summary>
/// The route the configuration document is served on; unset takes the canonical
/// well-known address derived from the issuer (SSF 1.0 Section 7.2). A set value is
/// deployment plumbing for a rewriting proxy that maps the canonical address onto an
/// internal route - the EXTERNAL address never moves, because receivers derive it from
/// the issuer.
/// </summary>
public PathString ConfigurationDocumentRoute { get; init; }

private static string? DefaultReceiverId(HttpContext context)
=> context.User.FindFirst(IanaClaimTypes.Sub)?.Value
?? context.User.Identity?.Name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ private static class Routes

/// <summary>
/// Maps the transmitter's endpoints: the Event Stream Management API under
/// <paramref name="prefix"/>, poll delivery beside it, and the configuration document at
/// the well-known address the issuer resolves to (SSF 1.0 Section 7.2).
/// <see cref="SsfEndpointOptions.ManagementPrefix"/>, poll delivery beside it, and the
/// configuration document at the well-known address the issuer resolves to
/// (SSF 1.0 Section 7.2). Every route comes from <see cref="SsfEndpointOptions"/>, so one
/// options object states the whole topology.
/// </summary>
/// <remarks>
/// The returned group carries the management and poll endpoints - attach the host's
Expand All @@ -69,27 +71,17 @@ private static class Routes
/// does not cover it.
/// </remarks>
/// <param name="endpoints">The route builder.</param>
/// <param name="prefix">The route prefix of the management surface.</param>
/// <param name="mapWellKnownConfiguration">
/// False leaves the well-known address unmapped - for a host whose gateway or CDN answers
/// the canonical address itself. The address is fixed by SSF 1.0 Section 7.2 and receivers
/// derive it from the issuer, so the flag only suppresses the route, never moves it; a host
/// that must serve the document on another internal path - a reverse proxy rewriting paths
/// in front of the transmitter - pairs this with
/// <see cref="MapSsfConfigurationDocument"/>.</param>
public static RouteGroupBuilder MapSsfTransmitterEndpoints(
this IEndpointRouteBuilder endpoints,
string prefix = "/ssf",
bool mapWellKnownConfiguration = true)
public static RouteGroupBuilder MapSsfTransmitterEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);

if (mapWellKnownConfiguration)
var endpointOptions = EndpointOptionsOf(endpoints);
if (endpointOptions.MapWellKnownConfiguration)
{
endpoints.MapSsfConfigurationDocument(prefix);
endpoints.MapSsfConfigurationDocument();
}

var group = endpoints.MapGroup(prefix);
var group = endpoints.MapGroup(endpointOptions.ManagementPrefix.Value ?? string.Empty);

// Every management response travels uncacheable, as the specification's own examples
// show (SSF 1.0 Section 8.1) - stream state answers are moments, not documents.
Expand All @@ -115,42 +107,44 @@ public static RouteGroupBuilder MapSsfTransmitterEndpoints(
}

/// <summary>
/// Maps the transmitter's configuration document (SSF 1.0 Section 7.2) on its own: at the
/// well-known address the issuer resolves to, or at a host-chosen internal route.
/// Maps the transmitter's configuration document (SSF 1.0 Section 7.2) on its own: at
/// <see cref="SsfEndpointOptions.ConfigurationDocumentRoute"/>, or at the well-known
/// address the issuer resolves to when that option is null.
/// </summary>
/// <remarks>
/// <see cref="MapSsfTransmitterEndpoints"/> calls this by default, so a plain host never
/// needs it. It exists for the deployment where the canonical address is answered by
/// something in front of the application: a gateway or CDN serving a cached copy (suppress
/// the default mapping and do not call this), or a reverse proxy rewriting paths, where the
/// document must exist on an internal route the proxy maps the canonical address onto. The
/// EXTERNAL address never moves - receivers derive it from the issuer, not from
/// configuration - so <paramref name="pattern"/> is deployment plumbing, not a protocol
/// choice.
/// something in front of the application: a gateway or CDN serving a cached copy (set
/// <see cref="SsfEndpointOptions.MapWellKnownConfiguration"/> to false and do not call
/// this), or a reverse proxy rewriting paths, where the document must exist on an internal
/// route the proxy maps the canonical address onto. The document advertises
/// <see cref="SsfEndpointOptions.AdvertisedPrefix"/> - the prefix as the outside world
/// reaches it. The EXTERNAL address never moves: receivers derive it from the issuer, not
/// from configuration, so the route option is deployment plumbing, not a protocol choice.
/// </remarks>
/// <param name="endpoints">The route builder.</param>
/// <param name="advertisedPrefix">
/// The management-surface prefix the document advertises, as the OUTSIDE world reaches it -
/// behind a rewriting proxy that is the external prefix, whatever
/// <see cref="MapSsfTransmitterEndpoints"/> mapped internally.</param>
/// <param name="pattern">
/// The route the document is served on; null takes the canonical well-known address derived
/// from the issuer.</param>
public static IEndpointConventionBuilder MapSsfConfigurationDocument(
this IEndpointRouteBuilder endpoints,
string advertisedPrefix = "/ssf",
string? pattern = null)
this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);

var endpointOptions = EndpointOptionsOf(endpoints);
var options = endpoints.ServiceProvider.GetRequiredService<SsfTransmitterOptions>();
var issuer = new Uri(options.Issuer, UriKind.Absolute);
var advertisedPrefix = endpointOptions.AdvertisedPrefix.HasValue
? endpointOptions.AdvertisedPrefix
: endpointOptions.ManagementPrefix;

return endpoints.MapGet(
pattern ?? TransmitterConfiguration.WellKnownAddress(issuer).AbsolutePath,
endpointOptions.ConfigurationDocumentRoute.HasValue
? endpointOptions.ConfigurationDocumentRoute.Value!
: TransmitterConfiguration.WellKnownAddress(issuer).AbsolutePath,
(SsfTransmitterOptions current) => Results.Json(ConfigurationDocumentOf(current, advertisedPrefix)));
}

private static SsfEndpointOptions EndpointOptionsOf(IEndpointRouteBuilder endpoints)
=> endpoints.ServiceProvider.GetService<SsfEndpointOptions>() ?? DefaultEndpointOptions;

/// <summary>
/// Maps the receiver's push intake (RFC 8935): the endpoint a transmitter POSTs SETs to,
/// answering the empty 202 or the 400 whose body speaks the registry vocabulary.
Expand Down Expand Up @@ -325,10 +319,10 @@ private static async Task<IResult> HandlePushAsync(
/// </summary>
private static TransmitterConfiguration ConfigurationDocumentOf(
SsfTransmitterOptions options,
string prefix)
PathString prefix)
{
var authority = new Uri(new Uri(options.Issuer, UriKind.Absolute).GetLeftPart(UriPartial.Authority));
Uri EndpointOf(string route) => new(authority, prefix + route);
Uri EndpointOf(string route) => new(authority, prefix.Add(route).Value!);

var deliveryMethods = new List<string> { PushDeliveryMethod.MethodUri };
if (options.PollEndpointFactory is not null)
Expand Down
17 changes: 13 additions & 4 deletions tests/Abblix.SharedSignals.E2E.Tests/SsfEndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,20 @@ public async Task GatewayFrontedTransmitter_SuppressesWellKnown_AndServesTheDocu
Issuer = TransmitterIssuer,
EventsSupported = [MembershipChanged],
});
builder.Services.AddSingleton(new SsfEndpointOptions { ReceiverIdSelector = _ => ReceiverId });
// The whole topology in one options object: internal routes, the external prefix the
// proxy exposes, and the suppressed canonical address the gateway owns.
builder.Services.AddSingleton(new SsfEndpointOptions
{
ReceiverIdSelector = _ => ReceiverId,
MapWellKnownConfiguration = false,
ManagementPrefix = "/internal/ssf",
AdvertisedPrefix = "/api/ssf",
ConfigurationDocumentRoute = "/internal/ssf-config",
});

await using var app = builder.Build();
app.MapSsfTransmitterEndpoints("/internal/ssf", mapWellKnownConfiguration: false);
app.MapSsfConfigurationDocument("/api/ssf", "/internal/ssf-config");
app.MapSsfTransmitterEndpoints();
app.MapSsfConfigurationDocument();
await app.StartAsync(cancellationToken);

var http = app.GetTestClient();
Expand Down Expand Up @@ -335,7 +344,7 @@ private async Task<WebApplication> StartTransmitterAsync()
});

var app = builder.Build();
app.MapSsfTransmitterEndpoints("/ssf");
app.MapSsfTransmitterEndpoints();
await app.StartAsync(TestContext.Current.CancellationToken);
return app;
}
Expand Down