using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts;
using FastEndpoints;
namespace DodoSSH.Api.Setup;
///
/// The single, explicit list of every endpoint.
///
///
/// FastEndpoints can find endpoints by scanning assemblies. It is deliberately not asked to. Explicit
/// registration gives predictable startup, survives trimming, and makes every route greppable from one
/// file — and a route that silently disappears because an assembly was not scanned is a genuinely nasty
/// failure. Scanning is worse here than in general: under WebApplicationFactory the scan reaches
/// the test assembly too, so an endpoint written in a test would be registered into the host under test.
/// The cost is one line per endpoint, and a line forgotten is a route missing with no compile error —
/// which is what EndpointInventoryTests exists to catch.
///
internal static class EndpointRegistration
{
///
/// The route template FastEndpoints maps whether or not it is wanted.
///
///
/// The registered template, not a request path: it is matched against what routing selected rather
/// than against anything a caller typed. FastEndpoints registers it without a leading slash.
///
private const string RouteTableTemplate = "_test_url_cache_";
internal static IServiceCollection AddDodoEndpoints(this IServiceCollection services) =>
services.AddFastEndpoints(new List
{
typeof(GetMetaEndpoint),
typeof(GetDodoSshConfigurationEndpoint),
typeof(GetMeEndpoint),
typeof(EnrollEndpoint),
typeof(RegisterDeviceEndpoint),
typeof(RevokeDeviceEndpoint),
typeof(LookupDirectoryEndpoint),
typeof(ReadKeyLogEndpoint),
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
typeof(VaultEventsEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint),
typeof(ArchiveTeamEndpoint),
typeof(TransferTeamOwnershipEndpoint),
typeof(ListTeamMembersEndpoint),
typeof(AddTeamMemberEndpoint),
typeof(ChangeTeamMemberRoleEndpoint),
typeof(RemoveTeamMemberEndpoint),
typeof(ListTeamInvitationsEndpoint),
typeof(CreateTeamInvitationEndpoint),
typeof(RevokeTeamInvitationEndpoint),
typeof(CreateTeamVaultEndpoint),
typeof(RenameVaultEndpoint),
typeof(DeleteVaultEndpoint),
typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint),
typeof(RevokeVaultGrantEndpoint),
typeof(RekeyVaultEndpoint),
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Vaults — per-item ACLs
// Relay — tickets and the WebSocket
// Audit, Admin
});
/// Hides the endpoint listing FastEndpoints publishes at GET /_test_url_cache_.
///
///
/// UseFastEndpoints maps that route unconditionally, in every environment, carrying neither a
/// policy nor AllowAnonymous. It answers with every endpoint class name and route template the
/// server knows, to back a test helper this repository does not use, and there is no switch to turn
/// it off. The deny-by-default policy means a caller has to be authenticated to read it, which is not
/// the same as it being nobody's business.
///
///
/// This asks routing which endpoint it selected rather than comparing the request path, because the
/// path cannot be compared correctly by hand: routing matches literal segments case-insensitively
/// and tolerates a trailing slash, so /_TEST_URL_CACHE_ and /_test_url_cache_/ reach
/// the same endpoint as the canonical spelling. A hand-written comparison that agrees with the
/// matcher on Monday is a bypass on Tuesday. WebApplication inserts UseRouting ahead of
/// every middleware registered here, so the selected endpoint is already available; short-circuiting
/// before UseEndpoints is what keeps the answer independent of registration order.
///
///
internal static WebApplication BlockFastEndpointsRouteTable(this WebApplication app)
{
app.Use(static async (context, next) =>
{
if (context.GetEndpoint() is RouteEndpoint selected
&& string.Equals(
selected.RoutePattern.RawText?.Trim('/'),
RouteTableTemplate,
StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next(context).ConfigureAwait(false);
});
return app;
}
internal static WebApplication MapDodoEndpoints(this WebApplication app)
{
app.UseFastEndpoints(config =>
{
// Every route is written out in full in its own Configure(). A global prefix would rewrite
// all of them at once, and /.well-known/ is not under /api at all.
config.Endpoints.RoutePrefix = null;
// FastEndpoints serialises through its own copy of the host's JsonOptions, taken implicitly
// at this point. Applied again explicitly because that copy is undocumented, and Setup/Json.cs
// records what silent JSON drift on this exact surface already cost once.
DodoSshJsonContext.ApplyTo(config.Serializer.Options);
// A body that will not deserialise is answered here, before any handler runs. The default
// body is not a problem document despite the media type it claims, and it names the failing
// .NET type; Problems.ForBindingFailure says the same thing in the shape everything else uses.
config.Errors.ProducesMetadataType = null;
config.Errors.ResponseBuilder =
static (_, context, statusCode) => Problems.ForBindingFailure(context, statusCode);
// Applied to every endpoint rather than endpoint by endpoint: the default is the dangerous
// one, so the safe choice has to be the one nobody can forget.
config.Endpoints.Configurator =
static endpoint => endpoint.RequestBinder(typeof(BodyOnlyRequestBinder<>));
});
return app;
}
}
///
/// Binds a request DTO from the JSON body and nothing else.
///
///
///
/// FastEndpoints' default binder deserialises the body and then writes route values, query-string
/// parameters, headers, claims and cookies over the top, matching DTO properties by name. Minimal APIs
/// bound a body parameter from the body alone, so leaving the default in place would silently widen
/// every request: ?cursor=… would override the cursor in a pull body, and — the reason this is
/// not merely untidy — ?identityProviderToken=… would let an ID token be supplied in a URL,
/// where proxies, browser history and access logs all keep copies of it. This API takes some trouble to
/// keep tokens out of logs; see IncludeErrorDetails = false in .
///
///
/// Route values are still read, deliberately and one at a time, with Route<T>("name") in
/// the handlers that need one. That reads the route directly rather than through the DTO, so it is
/// unaffected by this.
///
///
/// The request DTO being bound.
internal sealed class BodyOnlyRequestBinder()
: RequestBinder(BindingSource.JsonBody)
where TRequest : notnull;