search and ai stabilization work, localization stablized.

This commit is contained in:
master
2026-02-24 23:29:36 +02:00
parent 4f947a8b61
commit b07d27772e
766 changed files with 55299 additions and 3221 deletions

View File

@@ -0,0 +1,277 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Endpoints;
public static class IdentityProviderEndpoints
{
public static IEndpointRouteBuilder MapIdentityProviderEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/platform/identity-providers")
.WithTags("Identity Providers")
.RequireAuthorization(PlatformPolicies.IdentityProviderAdmin)
.RequireTenant();
group.MapGet("/", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
var items = await service.ListAsync(requestContext!.TenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(items);
});
group.MapGet("/{id:guid}", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
Guid id,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
var item = await service.GetAsync(requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false);
return item is null ? Results.NotFound() : Results.Ok(item);
});
group.MapPost("/", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
CreateIdentityProviderRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
try
{
var created = await service.CreateAsync(
requestContext!.TenantId,
requestContext.ActorId,
request,
cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/platform/identity-providers/{created.Id}", created);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
group.MapPut("/{id:guid}", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
Guid id,
UpdateIdentityProviderRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
try
{
var updated = await service.UpdateAsync(
requestContext!.TenantId,
requestContext.ActorId,
id,
request,
cancellationToken).ConfigureAwait(false);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
group.MapDelete("/{id:guid}", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
Guid id,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
var deleted = await service.DeleteAsync(requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false);
return deleted ? Results.NoContent() : Results.NotFound();
});
group.MapPost("/{id:guid}/enable", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
Guid id,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
var result = await service.SetEnabledAsync(
requestContext!.TenantId,
requestContext.ActorId,
id,
true,
cancellationToken).ConfigureAwait(false);
return result is null ? Results.NotFound() : Results.Ok(result);
});
group.MapPost("/{id:guid}/disable", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
Guid id,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
var result = await service.SetEnabledAsync(
requestContext!.TenantId,
requestContext.ActorId,
id,
false,
cancellationToken).ConfigureAwait(false);
return result is null ? Results.NotFound() : Results.Ok(result);
});
group.MapPost("/test-connection", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
TestConnectionRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out _, out var failure))
return failure!;
try
{
var result = await service.TestConnectionAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
group.MapGet("/{id:guid}/health", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
Guid id,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
var result = await service.GetHealthAsync(requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
});
group.MapPost("/{id:guid}/apply", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
IHttpClientFactory httpClientFactory,
ILogger<IdentityProviderManagementService> logger,
Guid id,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
return failure!;
var item = await service.GetAsync(requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false);
if (item is null)
return Results.NotFound();
try
{
var client = httpClientFactory.CreateClient("AuthorityInternal");
using var response = await client.PostAsync("internal/plugins/reload", null, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
logger.LogInformation(
"Authority plugin reload triggered for provider '{ProviderName}' ({ProviderId}).",
item.Name,
id);
return Results.Ok(new { applied = true, providerId = id, providerName = item.Name });
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogWarning(
"Authority plugin reload returned {StatusCode} for provider '{ProviderName}': {Body}",
(int)response.StatusCode,
item.Name,
body);
return Results.Ok(new { applied = false, providerId = id, providerName = item.Name, error = $"Authority returned {(int)response.StatusCode}" });
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.LogWarning(
ex,
"Failed to reach Authority reload endpoint for provider '{ProviderName}'. Config saved but not applied.",
item.Name);
return Results.Ok(new { applied = false, providerId = id, providerName = item.Name, error = "Authority unreachable; config saved but not applied." });
}
});
group.MapGet("/types", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
IdentityProviderManagementService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out _, out var failure))
return failure!;
var types = await service.GetTypeSchemasAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(types);
});
return app;
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
}

View File

@@ -0,0 +1,185 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Localization;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Services;
namespace StellaOps.Platform.WebService.Endpoints;
/// <summary>
/// REST API endpoints for translation management and serving UI bundles.
/// </summary>
public static class LocalizationEndpoints
{
public static IEndpointRouteBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder app)
{
// Anonymous UI bundle endpoint (same pattern as /platform/envsettings.json)
app.MapGet("/platform/i18n/{locale}.json", async (
string locale,
HttpContext context,
PlatformTranslationService translationService,
CancellationToken ct) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ct);
// Cache for 5 minutes — translations don't change often
context.Response.Headers.CacheControl = "public, max-age=300";
return Results.Ok(bundle);
})
.WithTags("Localization")
.WithName("GetUiTranslationBundle")
.WithSummary("Gets the merged translation bundle for the UI")
.WithDescription(
"Returns all translations for the specified locale, merging embedded defaults with DB overrides. " +
"Anonymous access, cacheable. Used by the Angular frontend at boot time.")
.AllowAnonymous();
// Authenticated API group
var group = app.MapGroup("/api/v1/platform/localization")
.WithTags("Localization")
.RequireAuthorization(PlatformPolicies.PreferencesRead)
.RequireTenant();
// Get all translations for a locale
group.MapGet("/bundles/{locale}", async (
string locale,
HttpContext context,
PlatformTranslationService translationService,
CancellationToken ct) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ct);
return Results.Ok(new
{
locale,
strings = bundle,
count = bundle.Count
});
})
.WithName("GetTranslationBundle")
.WithSummary("Gets all translations for a locale")
.WithDescription(
"Returns the merged set of all translations for the specified locale, " +
"combining embedded defaults with system and tenant DB overrides in priority order.");
// Get translations filtered by namespace
group.MapGet("/bundles/{locale}/{ns}", async (
string locale,
string ns,
HttpContext context,
PlatformTranslationService translationService,
CancellationToken ct) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ns, ct);
return Results.Ok(new
{
locale,
@namespace = ns,
strings = bundle,
count = bundle.Count
});
})
.WithName("GetTranslationBundleByNamespace")
.WithSummary("Gets translations for a locale filtered by namespace prefix");
// Get available locales
group.MapGet("/locales", async (
HttpContext context,
PlatformTranslationService translationService,
CancellationToken ct) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
var locales = await translationService.GetAllLocalesAsync(tenantId, ct);
return Results.Ok(new
{
locales,
count = locales.Count
});
})
.WithName("GetAvailableLocales")
.WithSummary("Gets all available locales");
// Upsert translations (admin)
group.MapPut("/bundles", async (
UpsertTranslationsRequest request,
HttpContext context,
ITranslationStore store,
TranslationRegistry registry,
CancellationToken ct) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
if (string.IsNullOrWhiteSpace(request.Locale))
{
return Results.BadRequest(new { error = "Locale is required." });
}
if (request.Strings is null || request.Strings.Count == 0)
{
return Results.BadRequest(new { error = "At least one translation string is required." });
}
await store.UpsertBatchAsync(tenantId, request.Locale, request.Strings, actor, ct);
// Also merge into the in-memory registry for immediate effect
registry.MergeBundles(request.Locale, request.Strings);
return Results.Ok(new
{
locale = request.Locale,
upserted = request.Strings.Count,
message = "Translations updated successfully."
});
})
.WithName("UpsertTranslations")
.WithSummary("Creates or updates translation strings")
.WithDescription(
"Upserts translation key-value pairs for a locale. DB values override embedded defaults. " +
"Changes take immediate effect in the in-memory registry.")
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
// Delete a translation (admin)
group.MapDelete("/strings/{locale}/{key}", async (
string locale,
string key,
HttpContext context,
ITranslationStore store,
CancellationToken ct) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
var deleted = await store.DeleteAsync(tenantId, locale, key, ct);
if (!deleted)
{
return Results.NotFound(new { error = $"Translation '{key}' not found for locale '{locale}'." });
}
return Results.Ok(new { message = $"Translation '{key}' deleted for locale '{locale}'." });
})
.WithName("DeleteTranslation")
.WithSummary("Deletes a translation override")
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
return app;
}
}
/// <summary>
/// Request to upsert translations.
/// </summary>
public sealed record UpsertTranslationsRequest
{
/// <summary>Target locale (e.g., "en-US", "de-DE").</summary>
public required string Locale { get; init; }
/// <summary>Key-value pairs to upsert.</summary>
public required Dictionary<string, string> Strings { get; init; }
}

View File

@@ -348,6 +348,44 @@ public static class PlatformEndpoints
}
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
preferences.MapGet("/language", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var locale = await service.GetLanguagePreferenceAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(locale);
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
preferences.MapPut("/language", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
PlatformLanguagePreferenceRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
try
{
var locale = await service.UpsertLanguagePreferenceAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(locale);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
var profiles = platform.MapGroup("/dashboard/profiles").WithTags("Platform Preferences");
profiles.MapGet("/", async Task<IResult> (
@@ -420,6 +458,8 @@ public static class PlatformEndpoints
return failure!;
}
ApplyLegacySearchDeprecationHeaders(context.Response.Headers);
var sources = query.Sources
?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
@@ -452,6 +492,14 @@ public static class PlatformEndpoints
.RequireAuthorization(PlatformPolicies.SearchRead);
}
private static void ApplyLegacySearchDeprecationHeaders(IHeaderDictionary headers)
{
headers["Deprecation"] = "true";
headers["Sunset"] = "2026-04-30T00:00:00Z";
headers["Link"] = "</api/v1/search/query>; rel=\"successor-version\"";
headers["Warning"] = "299 - Legacy platform search is deprecated; migrate to /api/v1/search/query";
}
private static void MapMetadataEndpoints(IEndpointRouteBuilder platform)
{
platform.MapGet("/metadata", async Task<IResult> (