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;