e2e observation fixes

This commit is contained in:
master
2026-02-18 22:47:34 +02:00
parent 1bcab39a2c
commit cb3e361fcf
35 changed files with 1127 additions and 177 deletions

View File

@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
using System;
namespace StellaOps.Auth.ServerIntegration;
@@ -22,7 +21,6 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
var requirement = new StellaOpsScopeRequirement(scopes);
builder.AddRequirements(requirement);
builder.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
return builder;
}
@@ -39,7 +37,6 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
options.AddPolicy(policyName, policy =>
{
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
});
}

View File

@@ -81,8 +81,13 @@ public static class StellaOpsLocalHostnameExtensions
return builder;
}
var httpsAvailable = IsPortAvailable(HttpsPort, resolvedIp);
var httpAvailable = IsPortAvailable(HttpPort, resolvedIp);
// When hostname resolves to a non-loopback address (common in Docker),
// bind on all interfaces so published host ports work regardless of
// which container interface Docker targets.
var bindIp = IPAddress.IsLoopback(resolvedIp) ? resolvedIp : IPAddress.Any;
var httpsAvailable = IsPortAvailable(HttpsPort, bindIp);
var httpAvailable = IsPortAvailable(HttpPort, bindIp);
if (!httpsAvailable && !httpAvailable)
{
@@ -92,14 +97,14 @@ public static class StellaOpsLocalHostnameExtensions
builder.Configuration[LocalBindingBoundKey] = "true";
// Bind to the specific loopback IP (not hostname) so Kestrel uses only
// this address, leaving other 127.1.0.x IPs available for other services.
// UseUrls("https://hostname") would bind to [::]:443 (all interfaces).
// Loopback-hostname mode: bind to the specific loopback IP so multiple
// local services can share 80/443 across different 127.1.0.x addresses.
// Container/non-loopback mode: bind to 0.0.0.0 so host port publishing
// works across all attached container interfaces.
//
// When ConfigureKestrel uses explicit Listen() calls, Kestrel ignores UseUrls.
// So we must also re-add the dev-port bindings from launchSettings.json.
var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? "";
var ip = resolvedIp;
builder.WebHost.ConfigureKestrel((context, kestrel) =>
{
// Re-add dev-port bindings from launchSettings.json / ASPNETCORE_URLS
@@ -126,7 +131,7 @@ public static class StellaOpsLocalHostnameExtensions
// Add .stella-ops.local bindings on the dedicated loopback IP
if (httpsAvailable)
{
kestrel.Listen(ip, HttpsPort, listenOptions =>
kestrel.Listen(bindIp, HttpsPort, listenOptions =>
{
listenOptions.UseHttps();
});
@@ -134,7 +139,7 @@ public static class StellaOpsLocalHostnameExtensions
if (httpAvailable)
{
kestrel.Listen(ip, HttpPort);
kestrel.Listen(bindIp, HttpPort);
}
});

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| U-002-AUTH-POLICY | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: remove hard auth-scheme binding that caused console-admin policy endpoints to throw when bearer scheme is not explicitly registered. |
| AUDIT-0083-M | DONE | Revalidated 2026-01-06. |
| AUDIT-0083-T | DONE | Revalidated 2026-01-06 (tests cover metadata caching, bypass checks, scope normalization). |
| AUDIT-0083-A | TODO | Reopened 2026-01-06: remove Guid.NewGuid fallback for correlation IDs; keep tests deterministic. |

View File

@@ -104,6 +104,30 @@ public sealed class PostgresDeadLetterRepository : IDeadLetterRepository
SELECT purge_dead_letter_entries(@retention_days, @batch_limit)
""";
private const string ActionableSummaryFunctionSql = """
SELECT error_code, category, entry_count, retryable_count, oldest_entry, sample_reason
FROM get_actionable_dead_letter_summary(@tenant_id, @limit)
""";
private const string ActionableSummaryFallbackSql = """
SELECT
error_code,
category,
COUNT(*)::bigint AS entry_count,
COUNT(*) FILTER (
WHERE is_retryable = TRUE
AND replay_attempts < max_replay_attempts
AND status = 'pending'
)::bigint AS retryable_count,
MIN(created_at) AS oldest_entry,
MIN(failure_reason) FILTER (WHERE failure_reason IS NOT NULL) AS sample_reason
FROM dead_letter_entries
WHERE tenant_id = @tenant_id
GROUP BY error_code, category
ORDER BY retryable_count DESC, entry_count DESC, oldest_entry ASC
LIMIT @limit
""";
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresDeadLetterRepository> _logger;
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
@@ -435,33 +459,38 @@ public sealed class PostgresDeadLetterRepository : IDeadLetterRepository
int limit,
CancellationToken cancellationToken)
{
const string sql = """
SELECT error_code, category, entry_count, retryable_count, oldest_entry, sample_reason
FROM get_actionable_dead_letter_summary(@tenant_id, @limit)
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("limit", limit);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var summaries = new List<DeadLetterSummary>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
try
{
var categoryStr = reader.GetString(1);
var category = Enum.TryParse<ErrorCategory>(categoryStr, true, out var cat) ? cat : ErrorCategory.Unknown;
summaries.Add(new DeadLetterSummary(
ErrorCode: reader.GetString(0),
Category: category,
EntryCount: reader.GetInt64(2),
RetryableCount: reader.GetInt64(3),
OldestEntry: reader.GetFieldValue<DateTimeOffset>(4),
SampleReason: reader.IsDBNull(5) ? null : reader.GetString(5)));
return await ReadActionableSummaryAsync(
connection,
ActionableSummaryFunctionSql,
tenantId,
limit,
cancellationToken).ConfigureAwait(false);
}
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedFunction)
{
_logger.LogWarning(
ex,
"Dead-letter summary function missing; falling back to direct table aggregation for tenant {TenantId}.",
tenantId);
return await ReadActionableSummaryAsync(
connection,
ActionableSummaryFallbackSql,
tenantId,
limit,
cancellationToken).ConfigureAwait(false);
}
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable)
{
_logger.LogWarning(
ex,
"Dead-letter table is not present; returning empty actionable summary for tenant {TenantId}.",
tenantId);
return [];
}
return summaries;
}
public async Task<int> MarkExpiredAsync(
@@ -575,6 +604,37 @@ public sealed class PostgresDeadLetterRepository : IDeadLetterRepository
UpdatedBy: reader.GetString(26));
}
private async Task<IReadOnlyList<DeadLetterSummary>> ReadActionableSummaryAsync(
NpgsqlConnection connection,
string sql,
string tenantId,
int limit,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("limit", limit);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var summaries = new List<DeadLetterSummary>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var categoryStr = reader.GetString(1);
var category = Enum.TryParse<ErrorCategory>(categoryStr, true, out var cat) ? cat : ErrorCategory.Unknown;
summaries.Add(new DeadLetterSummary(
ErrorCode: reader.GetString(0),
Category: category,
EntryCount: reader.GetInt64(2),
RetryableCount: reader.GetInt64(3),
OldestEntry: reader.GetFieldValue<DateTimeOffset>(4),
SampleReason: reader.IsDBNull(5) ? null : reader.GetString(5)));
}
return summaries;
}
private static (string sql, List<(string name, object value)> parameters) BuildListQuery(
string tenantId,
DeadLetterListOptions options)

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using StellaOps.Orchestrator.Core.Backfill;
using StellaOps.Orchestrator.Core.DeadLetter;
using StellaOps.Orchestrator.Core.Observability;
@@ -13,6 +14,8 @@ using StellaOps.Orchestrator.Infrastructure.Options;
using StellaOps.Orchestrator.Infrastructure.Postgres;
using StellaOps.Orchestrator.Infrastructure.Repositories;
using StellaOps.Orchestrator.Infrastructure.Services;
using System;
using System.Linq;
namespace StellaOps.Orchestrator.Infrastructure;
@@ -32,8 +35,24 @@ public static class ServiceCollectionExtensions
IConfiguration configuration)
{
// Register configuration options
services.Configure<OrchestratorServiceOptions>(
configuration.GetSection(OrchestratorServiceOptions.SectionName));
services.AddOptions<OrchestratorServiceOptions>()
.Bind(configuration.GetSection(OrchestratorServiceOptions.SectionName))
.PostConfigure(options =>
{
var fallbackConnection =
configuration.GetConnectionString("Default")
?? configuration["ConnectionStrings:Default"];
if (string.IsNullOrWhiteSpace(fallbackConnection))
{
return;
}
if (ShouldReplaceConnectionString(options.Database.ConnectionString))
{
options.Database.ConnectionString = fallbackConnection;
}
});
// Register data source
services.AddSingleton<OrchestratorDataSource>();
@@ -87,4 +106,36 @@ public static class ServiceCollectionExtensions
return services;
}
private static bool ShouldReplaceConnectionString(string? configuredConnectionString)
{
if (string.IsNullOrWhiteSpace(configuredConnectionString))
{
return true;
}
try
{
var builder = new NpgsqlConnectionStringBuilder(configuredConnectionString);
var host = builder.Host?.Trim();
if (string.IsNullOrWhiteSpace(host))
{
return true;
}
return host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.All(IsLoopbackHost);
}
catch
{
return false;
}
}
private static bool IsLoopbackHost(string host)
{
return host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase)
|| host.Equals("::1", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| U-002-ORCH-CONNECTION | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: harden local DB connection resolution to avoid deadletter/runtime failures from loopback connection strings in containers. |
| AUDIT-0422-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Orchestrator.Infrastructure. |
| AUDIT-0422-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Orchestrator.Infrastructure. |
| AUDIT-0422-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -1,9 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using Npgsql;
using StellaOps.Orchestrator.Core.DeadLetter;
using StellaOps.Orchestrator.Core.Domain;
using StellaOps.Orchestrator.WebService.Services;
using System;
using System.Globalization;
using System.Text;
namespace StellaOps.Orchestrator.WebService.Endpoints;
@@ -37,6 +40,10 @@ public static class DeadLetterEndpoints
.WithName("Orchestrator_GetDeadLetterStats")
.WithDescription("Get dead-letter statistics");
group.MapGet("export", ExportEntries)
.WithName("Orchestrator_ExportDeadLetterEntries")
.WithDescription("Export dead-letter entries as CSV");
group.MapGet("summary", GetActionableSummary)
.WithName("Orchestrator_GetDeadLetterSummary")
.WithDescription("Get actionable dead-letter summary grouped by error code");
@@ -128,6 +135,10 @@ public static class DeadLetterEndpoints
{
return Results.BadRequest(new { error = ex.Message });
}
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
{
return Results.Ok(new DeadLetterListResponse(new List<DeadLetterEntryResponse>(), null, 0));
}
}
private static async Task<IResult> GetEntry(
@@ -154,6 +165,10 @@ public static class DeadLetterEndpoints
{
return Results.BadRequest(new { error = ex.Message });
}
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
{
return Results.NotFound();
}
}
private static async Task<IResult> GetEntryByJobId(
@@ -180,6 +195,10 @@ public static class DeadLetterEndpoints
{
return Results.BadRequest(new { error = ex.Message });
}
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
{
return Results.NotFound();
}
}
private static async Task<IResult> GetStats(
@@ -200,6 +219,56 @@ public static class DeadLetterEndpoints
{
return Results.BadRequest(new { error = ex.Message });
}
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
{
return Results.Ok(DeadLetterStatsResponse.FromDomain(CreateEmptyStats()));
}
}
private static async Task<IResult> ExportEntries(
HttpContext context,
[FromServices] TenantResolver tenantResolver,
[FromServices] IDeadLetterRepository repository,
[FromQuery] string? status = null,
[FromQuery] string? category = null,
[FromQuery] string? jobType = null,
[FromQuery] string? errorCode = null,
[FromQuery] bool? isRetryable = null,
[FromQuery] int? limit = null,
CancellationToken cancellationToken = default)
{
try
{
var tenantId = tenantResolver.Resolve(context);
var effectiveLimit = Math.Clamp(limit ?? 1000, 1, 10000);
var options = new DeadLetterListOptions(
Status: TryParseDeadLetterStatus(status),
Category: TryParseErrorCategory(category),
JobType: jobType,
ErrorCode: errorCode,
IsRetryable: isRetryable,
Limit: effectiveLimit);
var entries = await repository.ListAsync(tenantId, options, cancellationToken)
.ConfigureAwait(false);
var csv = BuildDeadLetterCsv(entries);
var payload = Encoding.UTF8.GetBytes(csv);
var fileName = $"deadletter-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
return Results.File(payload, "text/csv", fileName);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
{
var payload = Encoding.UTF8.GetBytes(BuildDeadLetterCsv(Array.Empty<DeadLetterEntry>()));
var fileName = $"deadletter-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
return Results.File(payload, "text/csv", fileName);
}
}
private static async Task<IResult> GetActionableSummary(
@@ -230,6 +299,10 @@ public static class DeadLetterEndpoints
{
return Results.BadRequest(new { error = ex.Message });
}
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
{
return Results.Ok(new DeadLetterSummaryListResponse(new List<DeadLetterSummaryResponse>()));
}
}
private static async Task<IResult> ReplayEntry(
@@ -476,6 +549,58 @@ public static class DeadLetterEndpoints
private static string GetCurrentUser(HttpContext context) =>
context.User?.Identity?.Name ?? "anonymous";
private static bool IsMissingDeadLetterTable(PostgresException exception) =>
string.Equals(exception.SqlState, "42P01", StringComparison.Ordinal);
private static DeadLetterStats CreateEmptyStats() =>
new(
TotalEntries: 0,
PendingEntries: 0,
ReplayingEntries: 0,
ReplayedEntries: 0,
ResolvedEntries: 0,
ExhaustedEntries: 0,
ExpiredEntries: 0,
RetryableEntries: 0,
ByCategory: new Dictionary<ErrorCategory, long>(),
TopErrorCodes: new Dictionary<string, long>(),
TopJobTypes: new Dictionary<string, long>());
private static string BuildDeadLetterCsv(IReadOnlyList<DeadLetterEntry> entries)
{
var builder = new StringBuilder();
builder.AppendLine("entryId,jobId,status,errorCode,category,retryable,replayAttempts,maxReplayAttempts,failedAt,createdAt,resolvedAt,reason");
foreach (var entry in entries)
{
builder.Append(EscapeCsv(entry.EntryId.ToString())).Append(',');
builder.Append(EscapeCsv(entry.OriginalJobId.ToString())).Append(',');
builder.Append(EscapeCsv(entry.Status.ToString())).Append(',');
builder.Append(EscapeCsv(entry.ErrorCode)).Append(',');
builder.Append(EscapeCsv(entry.Category.ToString())).Append(',');
builder.Append(EscapeCsv(entry.IsRetryable.ToString(CultureInfo.InvariantCulture))).Append(',');
builder.Append(EscapeCsv(entry.ReplayAttempts.ToString(CultureInfo.InvariantCulture))).Append(',');
builder.Append(EscapeCsv(entry.MaxReplayAttempts.ToString(CultureInfo.InvariantCulture))).Append(',');
builder.Append(EscapeCsv(entry.FailedAt.ToString("O", CultureInfo.InvariantCulture))).Append(',');
builder.Append(EscapeCsv(entry.CreatedAt.ToString("O", CultureInfo.InvariantCulture))).Append(',');
builder.Append(EscapeCsv(entry.ResolvedAt?.ToString("O", CultureInfo.InvariantCulture))).Append(',');
builder.Append(EscapeCsv(entry.FailureReason));
builder.AppendLine();
}
return builder.ToString();
}
private static string EscapeCsv(string? value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
}
}
// Response DTOs

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| U-002-ORCH-DEADLETTER | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: add/fix deadletter API behavior used by console actions (including export route) and validate local setup usability paths. |
| AUDIT-0425-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Orchestrator.WebService. |
| AUDIT-0425-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Orchestrator.WebService. |
| AUDIT-0425-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -491,7 +491,7 @@ public static class PlatformEndpoints
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(BuildLegacyEntitlement(summary.Value, requestContext!));
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
quotas.MapGet("/consumption", async Task<IResult> (
HttpContext context,
@@ -506,7 +506,7 @@ public static class PlatformEndpoints
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(BuildLegacyConsumption(summary.Value));
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
quotas.MapGet("/dashboard", async Task<IResult> (
HttpContext context,
@@ -528,7 +528,7 @@ public static class PlatformEndpoints
activeAlerts = 0,
recentViolations = 0
});
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
quotas.MapGet("/history", async Task<IResult> (
HttpContext context,
@@ -570,7 +570,7 @@ public static class PlatformEndpoints
points,
aggregation = string.IsNullOrWhiteSpace(aggregation) ? "daily" : aggregation
});
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
quotas.MapGet("/tenants", async Task<IResult> (
HttpContext context,
@@ -612,7 +612,7 @@ public static class PlatformEndpoints
.ToArray();
return Results.Ok(new { items, total = 1 });
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
quotas.MapGet("/tenants/{tenantId}", async Task<IResult> (
HttpContext context,
@@ -655,7 +655,7 @@ public static class PlatformEndpoints
},
forecast = BuildLegacyForecast("api")
});
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
quotas.MapGet("/forecast", async Task<IResult> (
HttpContext context,
@@ -673,7 +673,7 @@ public static class PlatformEndpoints
var forecasts = categories.Select(BuildLegacyForecast).ToArray();
return Results.Ok(forecasts);
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
quotas.MapGet("/alerts", (HttpContext context, PlatformRequestContextResolver resolver) =>
{
@@ -694,7 +694,7 @@ public static class PlatformEndpoints
channels = Array.Empty<object>(),
escalationMinutes = 30
}));
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
quotas.MapPost("/alerts", (HttpContext context, PlatformRequestContextResolver resolver, [FromBody] object config) =>
{
@@ -704,7 +704,7 @@ public static class PlatformEndpoints
}
return Task.FromResult<IResult>(Results.Ok(config));
}).RequireAuthorization(PlatformPolicies.QuotaAdmin);
}).RequireAuthorization();
var rateLimits = app.MapGroup("/api/v1/gateway/rate-limits")
.WithTags("Platform Gateway Compatibility");
@@ -729,7 +729,7 @@ public static class PlatformEndpoints
burstRemaining = 119
}
}));
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
rateLimits.MapGet("/violations", (HttpContext context, PlatformRequestContextResolver resolver) =>
{
@@ -749,7 +749,7 @@ public static class PlatformEndpoints
end = now.ToString("o")
}
}));
}).RequireAuthorization(PlatformPolicies.QuotaRead);
}).RequireAuthorization();
}
private static LegacyQuotaItem[] BuildLegacyConsumption(IReadOnlyList<PlatformQuotaUsage> usage)

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| U-002-PLATFORM-COMPAT | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: unblock local console usability by fixing legacy compatibility endpoint auth failures for authenticated admin usage. |
| QA-PLATFORM-VERIFY-001 | DONE | run-002 verification completed; feature terminalized as `not_implemented` due missing advisory lock and LISTEN/NOTIFY implementation signals in `src/Platform` (materialized-view/rollup behaviors verified). |
| QA-PLATFORM-VERIFY-002 | DONE | run-001 verification passed with maintenance, endpoint (503 + success), service caching, and schema integration evidence; feature moved to `docs/features/checked/platform/materialized-views-for-analytics.md`. |
| QA-PLATFORM-VERIFY-003 | DONE | run-001 verification passed with API aggregation endpoint behavior evidence (live HTTP request/response captures + endpoint tests, `98/98` assembly tests after quota/search gap tests); feature moved to `docs/features/checked/platform/platform-service-aggregation-layer.md`. |

View File

@@ -82,6 +82,11 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
Accept: 'application/json',
});
const accessToken = this.authSession.session()?.tokens.accessToken;
if (accessToken) {
headers = headers.set('Authorization', `Bearer ${accessToken}`);
}
if (projectId) headers = headers.set('X-Stella-Project', projectId);
return headers;

View File

@@ -82,6 +82,14 @@ import {
RollbackPolicyResponse,
} from './policy-engine.models';
interface GovernanceSealedModeStatusResponse {
readonly isSealed: boolean;
readonly sealedAt?: string | null;
readonly lastUnsealedAt?: string | null;
readonly trustRoots?: readonly string[];
readonly lastVerifiedAt?: string | null;
}
/**
* Policy Engine API interface for dependency injection.
*/
@@ -441,7 +449,25 @@ export class PolicyEngineHttpClient implements PolicyEngineApi {
getSealedStatus(options: Pick<PolicyQueryOptions, 'tenantId' | 'traceId'>): Observable<SealedModeStatus> {
const headers = this.buildHeaders(options);
return this.http.get<SealedModeStatus>(`${this.baseUrl}/system/airgap/status`, { headers });
let params = new HttpParams();
if (options.tenantId) {
params = params.set('tenantId', options.tenantId);
}
return this.http
.get<GovernanceSealedModeStatusResponse>(
`${this.baseUrl}/api/v1/governance/sealed-mode/status`,
{ headers, params }
)
.pipe(
map((response): SealedModeStatus => ({
isSealed: response.isSealed,
sealedAt: response.sealedAt ?? null,
unsealedAt: response.lastUnsealedAt ?? null,
trustRoots: [...(response.trustRoots ?? [])],
lastVerifiedAt: response.lastVerifiedAt ?? null,
}))
);
}
verifyBundle(request: BundleVerifyRequest, options: Pick<PolicyQueryOptions, 'tenantId' | 'traceId'>): Observable<BundleVerifyResponse> {

View File

@@ -299,6 +299,12 @@ const MOCK_AUDIT_EVENTS: GovernanceAuditEvent[] = [
},
];
const RISK_PROFILE_ID_ALIASES: Readonly<Record<string, string>> = {
default: 'profile-default',
strict: 'profile-strict',
dev: 'profile-dev',
};
/**
* Mock Policy Governance API implementation.
*/
@@ -317,6 +323,20 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
lastVerifiedAt: '2025-12-28T12:00:00Z',
};
private canonicalProfileId(profileId: string): string {
const normalized = profileId.trim();
if (this.riskProfiles.some((profile) => profile.id === normalized)) {
return normalized;
}
return RISK_PROFILE_ID_ALIASES[normalized.toLowerCase()] ?? normalized;
}
private findProfileIndex(profileId: string): number {
const canonicalId = this.canonicalProfileId(profileId);
return this.riskProfiles.findIndex((profile) => profile.id === canonicalId);
}
// Risk Budget
getRiskBudgetDashboard(options: GovernanceQueryOptions): Observable<RiskBudgetDashboard> {
const dashboard: RiskBudgetDashboard = {
@@ -628,7 +648,7 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
}
getRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
const profile = this.riskProfiles.find((p) => p.id === profileId);
const profile = this.riskProfiles.find((p) => p.id === this.canonicalProfileId(profileId));
if (!profile) {
return throwError(() => new Error(`Profile ${profileId} not found`));
}
@@ -657,14 +677,15 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
}
updateRiskProfile(profileId: string, profile: Partial<RiskProfileGov>, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
const idx = this.riskProfiles.findIndex((p) => p.id === profileId);
const idx = this.findProfileIndex(profileId);
if (idx < 0) {
return throwError(() => new Error(`Profile ${profileId} not found`));
}
const canonicalId = this.riskProfiles[idx].id;
const updated: RiskProfileGov = {
...this.riskProfiles[idx],
...profile,
id: profileId,
id: canonicalId,
modifiedAt: new Date().toISOString(),
modifiedBy: 'current-user',
};
@@ -673,12 +694,13 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
}
deleteRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<void> {
this.riskProfiles = this.riskProfiles.filter((p) => p.id !== profileId);
const canonicalId = this.canonicalProfileId(profileId);
this.riskProfiles = this.riskProfiles.filter((p) => p.id !== canonicalId);
return of(undefined).pipe(delay(100));
}
activateRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
const idx = this.riskProfiles.findIndex((p) => p.id === profileId);
const idx = this.findProfileIndex(profileId);
if (idx < 0) {
return throwError(() => new Error(`Profile ${profileId} not found`));
}
@@ -687,7 +709,7 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
}
deprecateRiskProfile(profileId: string, reason: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
const idx = this.riskProfiles.findIndex((p) => p.id === profileId);
const idx = this.findProfileIndex(profileId);
if (idx < 0) {
return throwError(() => new Error(`Profile ${profileId} not found`));
}

View File

@@ -47,6 +47,7 @@ export type AuthStatus =
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
export const FULL_SESSION_STORAGE_KEY = 'stellaops.auth.session.full';
export type AuthErrorReason =
| 'invalid_state'

View File

@@ -1,29 +1,34 @@
import { TestBed } from '@angular/core/testing';
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
import {
AuthSession,
AuthTokens,
FULL_SESSION_STORAGE_KEY,
SESSION_STORAGE_KEY,
} from './auth-session.model';
import { AuthSessionStore } from './auth-session.store';
describe('AuthSessionStore', () => {
let store: AuthSessionStore;
beforeEach(() => {
sessionStorage.clear();
function createStore(): AuthSessionStore {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [AuthSessionStore],
});
store = TestBed.inject(AuthSessionStore);
});
return TestBed.inject(AuthSessionStore);
}
it('persists minimal metadata when session is set', () => {
function createSession(expiresAtEpochMs: number = Date.now() + 120_000): AuthSession {
const tokens: AuthTokens = {
accessToken: 'token-abc',
expiresAtEpochMs: Date.now() + 120_000,
expiresAtEpochMs,
refreshToken: 'refresh-xyz',
scope: 'openid ui.read',
tokenType: 'Bearer',
};
const session: AuthSession = {
return {
tokens,
identity: {
subject: 'user-123',
@@ -39,6 +44,15 @@ describe('AuthSessionStore', () => {
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
};
}
beforeEach(() => {
sessionStorage.clear();
store = createStore();
});
it('persists metadata and full session when session is set', () => {
const session = createSession();
store.setSession(session);
@@ -48,8 +62,42 @@ describe('AuthSessionStore', () => {
expect(parsed.subject).toBe('user-123');
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
expect(parsed.tenantId).toBe('tenant-default');
expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeTruthy();
store.clear();
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeNull();
});
it('rehydrates authenticated session from full session storage', () => {
const session = createSession();
store.setSession(session);
const rehydrated = createStore();
expect(rehydrated.status()).toBe('authenticated');
expect(rehydrated.isAuthenticated()).toBeTrue();
expect(rehydrated.subjectHint()).toBe('user-123');
expect(rehydrated.session()?.tokens.accessToken).toBe('token-abc');
});
it('drops expired persisted full session and keeps unauthenticated state', () => {
const expired = createSession(Date.now() - 5_000);
sessionStorage.setItem(FULL_SESSION_STORAGE_KEY, JSON.stringify(expired));
sessionStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({
subject: expired.identity.subject,
expiresAtEpochMs: expired.tokens.expiresAtEpochMs,
issuedAtEpochMs: expired.issuedAtEpochMs,
dpopKeyThumbprint: expired.dpopKeyThumbprint,
tenantId: expired.tenantId,
})
);
const rehydrated = createStore();
expect(rehydrated.isAuthenticated()).toBeFalse();
expect(rehydrated.session()).toBeNull();
expect(rehydrated.subjectHint()).toBe('user-123');
expect(sessionStorage.getItem(FULL_SESSION_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -3,6 +3,7 @@ import { Injectable, computed, signal } from '@angular/core';
import {
AuthSession,
AuthStatus,
FULL_SESSION_STORAGE_KEY,
PersistedSessionMetadata,
SESSION_STORAGE_KEY,
} from './auth-session.model';
@@ -11,10 +12,16 @@ import {
providedIn: 'root',
})
export class AuthSessionStore {
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
private readonly persistedSignal =
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
private readonly restoredSession = this.readPersistedSession();
private readonly sessionSignal = signal<AuthSession | null>(
this.restoredSession
);
private readonly statusSignal = signal<AuthStatus>(
this.restoredSession ? 'authenticated' : 'unauthenticated'
);
private readonly persistedSignal = signal<PersistedSessionMetadata | null>(
this.readPersistedMetadata(this.restoredSession)
);
readonly session = computed(() => this.sessionSignal());
readonly status = computed(() => this.statusSignal());
@@ -52,19 +59,15 @@ export class AuthSessionStore {
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
this.clearPersistedSession();
return;
}
this.statusSignal.set('authenticated');
const metadata: PersistedSessionMetadata = {
subject: session.identity.subject,
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
issuedAtEpochMs: session.issuedAtEpochMs,
dpopKeyThumbprint: session.dpopKeyThumbprint,
tenantId: session.tenantId,
};
const metadata = this.toMetadata(session);
this.persistedSignal.set(metadata);
this.persistMetadata(metadata);
this.persistSession(session);
}
clear(): void {
@@ -72,9 +75,12 @@ export class AuthSessionStore {
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
this.clearPersistedSession();
}
private readPersistedMetadata(): PersistedSessionMetadata | null {
private readPersistedMetadata(
restoredSession: AuthSession | null
): PersistedSessionMetadata | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
@@ -82,7 +88,12 @@ export class AuthSessionStore {
try {
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return null;
if (!restoredSession) {
return null;
}
const metadata = this.toMetadata(restoredSession);
this.persistMetadata(metadata);
return metadata;
}
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
if (
@@ -91,7 +102,8 @@ export class AuthSessionStore {
typeof parsed.issuedAtEpochMs !== 'number' ||
typeof parsed.dpopKeyThumbprint !== 'string'
) {
return null;
sessionStorage.removeItem(SESSION_STORAGE_KEY);
return restoredSession ? this.toMetadata(restoredSession) : null;
}
const tenantId =
typeof parsed.tenantId === 'string'
@@ -105,8 +117,84 @@ export class AuthSessionStore {
tenantId,
};
} catch {
sessionStorage.removeItem(SESSION_STORAGE_KEY);
return restoredSession ? this.toMetadata(restoredSession) : null;
}
}
private readPersistedSession(): AuthSession | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
try {
const raw = sessionStorage.getItem(FULL_SESSION_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as AuthSession;
if (!this.isValidSession(parsed)) {
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
return null;
}
if (parsed.tokens.expiresAtEpochMs <= Date.now()) {
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
return null;
}
return parsed;
} catch {
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
return null;
}
}
private isValidSession(session: AuthSession | null): session is AuthSession {
if (!session) {
return false;
}
const tokens = session.tokens;
const identity = session.identity;
if (
!tokens ||
typeof tokens.accessToken !== 'string' ||
typeof tokens.expiresAtEpochMs !== 'number' ||
typeof tokens.scope !== 'string'
) {
return false;
}
if (
!identity ||
typeof identity.subject !== 'string' ||
!Array.isArray(identity.roles)
) {
return false;
}
if (
typeof session.dpopKeyThumbprint !== 'string' ||
typeof session.issuedAtEpochMs !== 'number' ||
!Array.isArray(session.scopes) ||
!Array.isArray(session.audiences)
) {
return false;
}
return true;
}
private toMetadata(session: AuthSession): PersistedSessionMetadata {
return {
subject: session.identity.subject,
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
issuedAtEpochMs: session.issuedAtEpochMs,
dpopKeyThumbprint: session.dpopKeyThumbprint,
tenantId: session.tenantId,
};
}
private persistMetadata(metadata: PersistedSessionMetadata): void {
@@ -116,6 +204,13 @@ export class AuthSessionStore {
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
}
private persistSession(session: AuthSession): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(FULL_SESSION_STORAGE_KEY, JSON.stringify(session));
}
private clearPersistedMetadata(): void {
if (typeof sessionStorage === 'undefined') {
return;
@@ -123,6 +218,13 @@ export class AuthSessionStore {
sessionStorage.removeItem(SESSION_STORAGE_KEY);
}
private clearPersistedSession(): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.removeItem(FULL_SESSION_STORAGE_KEY);
}
getActiveTenantId(): string | null {
return this.tenantId();
}

View File

@@ -588,7 +588,7 @@ export class ImpactPreviewComponent implements OnInit {
setTimeout(() => {
this.applying.set(false);
// Navigate back to trust weights
window.location.href = '/admin/policy/governance/trust-weights';
window.location.href = '/policy/governance/trust-weights';
}, 1500);
}
}

View File

@@ -351,7 +351,7 @@ describe('PolicyAuditLogComponent', () => {
component.viewDiff(entry);
expect(mockRouter.navigate).toHaveBeenCalledWith(
['/admin/policy/simulation/diff', 'policy-pack-001'],
['/policy/simulation/diff', 'policy-pack-001'],
{ queryParams: { from: 1, to: 2 } }
);
});

View File

@@ -628,7 +628,7 @@ export class PolicyAuditLogComponent implements OnInit {
viewDiff(entry: PolicyAuditEntry): void {
if (entry.diffId && entry.policyVersion) {
this.router.navigate(['/admin/policy/simulation/diff', entry.policyPackId], {
this.router.navigate(['/policy/simulation/diff', entry.policyPackId], {
queryParams: {
from: entry.policyVersion - 1,
to: entry.policyVersion,

View File

@@ -1,7 +1,7 @@
/**
* @file policy-simulation.routes.ts
* @sprint SPRINT_20251229_021b_FE
* @description Routes for Policy Simulation Studio at /admin/policy/simulation
* @description Routes for Policy Simulation Studio at /policy/simulation
*/
import { Routes } from '@angular/router';

View File

@@ -228,12 +228,12 @@ describe('SimulationDashboardComponent', () => {
});
describe('Navigation', () => {
it('should navigate to shadow on viewResults', fakeAsync(() => {
it('should navigate to history on viewResults', fakeAsync(() => {
spyOn(router, 'navigate');
component['navigateToShadow']();
component['navigateToHistory']();
expect(router.navigate).toHaveBeenCalledWith(['/admin/policy/simulation/shadow']);
expect(router.navigate).toHaveBeenCalledWith(['/policy/simulation/history']);
}));
it('should navigate to promotion on navigateToPromotion', fakeAsync(() => {
@@ -241,7 +241,7 @@ describe('SimulationDashboardComponent', () => {
component['navigateToPromotion']();
expect(router.navigate).toHaveBeenCalledWith(['/admin/policy/simulation/promotion']);
expect(router.navigate).toHaveBeenCalledWith(['/policy/simulation/promotion']);
}));
});

View File

@@ -45,7 +45,7 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
[showActions]="true"
(enable)="enableShadowMode()"
(disable)="disableShadowMode()"
(viewResults)="navigateToShadow()"
(viewResults)="navigateToHistory()"
/>
</div>
@@ -618,11 +618,11 @@ export class SimulationDashboardComponent implements OnInit {
});
}
protected navigateToShadow(): void {
this.router.navigate(['/admin/policy/simulation/shadow']);
protected navigateToHistory(): void {
this.router.navigate(['/policy/simulation/history']);
}
protected navigateToPromotion(): void {
this.router.navigate(['/admin/policy/simulation/promotion']);
this.router.navigate(['/policy/simulation/promotion']);
}
}

View File

@@ -198,7 +198,7 @@ describe('SimulationHistoryComponent', () => {
component.viewSimulation('sim-001');
expect(router.navigate).toHaveBeenCalledWith(
['/admin/policy/simulation/console'],
['/policy/simulation/console'],
{ queryParams: { simulationId: 'sim-001' } }
);
});

View File

@@ -1170,7 +1170,7 @@ export class SimulationHistoryComponent implements OnInit {
}
viewSimulation(simulationId: string): void {
this.router.navigate(['/admin/policy/simulation/console'], {
this.router.navigate(['/policy/simulation/console'], {
queryParams: { simulationId },
});
}

View File

@@ -1088,8 +1088,6 @@ export class PolicyStudioComponent implements OnInit {
}
viewProfile(profile: RiskProfileSummary): void {
this.store.loadProfile(profile.profileId, { tenantId: this.tenantId });
this.store.loadProfileVersions(profile.profileId, { tenantId: this.tenantId });
this.router.navigate(['/policy/governance/profiles', profile.profileId]);
}

View File

@@ -3063,6 +3063,14 @@ export class StepContentComponent {
readonly newRegistryProvider = signal<string | null>(null);
readonly newScmProvider = signal<string | null>(null);
readonly newNotifyProvider = signal<string | null>(null);
private legacyMirrorDefaultsSanitized = false;
private static readonly LEGACY_MIRROR_ENDPOINT_DEFAULTS = new Set([
'https://mirror.stella-ops.org/feeds',
'https://mirror.stella-ops.org/feeds/',
'https://mirrors.stella-ops.org/feeds',
'https://mirrors.stella-ops.org/feeds/',
]);
/** Sensible defaults for local/development setup. */
private static readonly LOCAL_DEFAULTS: Record<string, Record<string, string>> = {
@@ -3128,6 +3136,21 @@ export class StepContentComponent {
if (sourceMode && !this.sourceFeedMode()) {
this.sourceFeedMode.set(sourceMode);
}
const mirrorUrlRaw = config['sources.mirror.url'];
const mirrorUrl = typeof mirrorUrlRaw === 'string'
? mirrorUrlRaw.trim().toLowerCase()
: '';
if (
!this.legacyMirrorDefaultsSanitized &&
StepContentComponent.LEGACY_MIRROR_ENDPOINT_DEFAULTS.has(mirrorUrl)
) {
this.legacyMirrorDefaultsSanitized = true;
this.configChange.emit({ key: 'sources.mirror.url', value: '' });
this.configChange.emit({ key: 'sources.mirror.apiKey', value: '' });
this.sourceFeedMode.set('custom');
this.configChange.emit({ key: 'sources.mode', value: 'custom' });
}
});
// Source feed mode: 'mirror' (Stella Ops pre-aggregated) or 'custom' (individual feeds)

View File

@@ -718,9 +718,12 @@ export class VexStatementSearchComponent implements OnInit {
try {
const result = await firstValueFrom(this.vexHubApi.searchStatements(params));
this.statements.set(result.items);
this.total.set(result.total);
const items = Array.isArray(result?.items) ? result.items : [];
this.statements.set(items);
this.total.set(typeof result?.total === 'number' ? result.total : items.length);
} catch (err) {
this.statements.set([]);
this.total.set(0);
this.error.set(err instanceof Error ? err.message : 'Search failed');
} finally {
this.loading.set(false);

View File

@@ -9,12 +9,20 @@ import {
Component,
computed,
inject,
OnDestroy,
signal,
} from '@angular/core';
import { AppConfigService } from '../../core/config/app-config.service';
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
declare global {
interface Window {
__stellaWelcomeSignIn?: (() => void) | null;
__stellaWelcomePendingSignIn?: boolean;
}
}
@Component({
selector: 'app-welcome-page',
imports: [],
@@ -81,8 +89,8 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
<!-- Actions -->
<div class="actions">
<button type="button" class="cta" (click)="signIn()">
<span class="cta__label">Sign In</span>
<button type="button" class="cta" [disabled]="signingIn()" (click)="signIn()">
<span class="cta__label">{{ !interactionReady() ? 'Preparing Sign-In...' : (signingIn() ? 'Signing In...' : 'Sign In') }}</span>
<svg class="cta__arrow" viewBox="0 0 24 24" width="16" height="16"
fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
@@ -659,10 +667,16 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
}
`]
})
export class WelcomePageComponent {
export class WelcomePageComponent implements OnDestroy {
private readonly configService = inject(AppConfigService);
private readonly authService = inject(AuthorityAuthService);
private readonly globalSignInTrigger = () => {
void this.signIn();
};
readonly authNotice = signal<string | null>(null);
readonly signingIn = signal(false);
readonly interactionReady = signal(false);
readonly pendingSignIn = signal(false);
readonly config = computed(() => this.configService.config);
readonly title = computed(
@@ -683,7 +697,49 @@ export class WelcomePageComponent {
return secure.toString();
});
signIn(): void {
constructor() {
// Ensure the primary action is wired as soon as browser bootstrap begins.
if (typeof window !== 'undefined') {
window.__stellaWelcomeSignIn = this.globalSignInTrigger;
window.setTimeout(() => {
this.interactionReady.set(true);
if (this.pendingSignIn() || window.__stellaWelcomePendingSignIn) {
this.pendingSignIn.set(false);
window.__stellaWelcomePendingSignIn = false;
void this.signIn();
}
}, 0);
}
}
ngOnDestroy(): void {
if (
typeof window !== 'undefined' &&
window.__stellaWelcomeSignIn === this.globalSignInTrigger
) {
window.__stellaWelcomeSignIn = null;
}
}
async signIn(): Promise<void> {
if (this.signingIn()) {
return;
}
if (!this.interactionReady()) {
this.pendingSignIn.set(true);
if (typeof window !== 'undefined') {
window.__stellaWelcomePendingSignIn = true;
}
this.authNotice.set('Preparing secure sign-in...');
return;
}
this.pendingSignIn.set(false);
if (typeof window !== 'undefined') {
window.__stellaWelcomePendingSignIn = false;
}
if (typeof window !== 'undefined' && window.location.protocol === 'http:') {
const secureUrl = this.secureUrl();
if (secureUrl) {
@@ -696,7 +752,35 @@ export class WelcomePageComponent {
);
return;
}
this.signingIn.set(true);
this.authNotice.set(null);
void this.authService.beginLogin('/');
try {
if (!this.configService.isConfigured()) {
await this.configService.load();
}
if (!this.configService.isConfigured()) {
this.authNotice.set('Sign-in configuration is still loading. Please try again in a moment.');
return;
}
await this.authService.beginLogin('/');
// First click can occasionally race with early-runtime auth bootstrap;
// retry once if we are still on the welcome page after a short delay.
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/welcome')) {
window.setTimeout(() => {
if (window.location.pathname.startsWith('/welcome')) {
void this.authService.beginLogin('/');
}
}, 250);
}
} catch {
this.authNotice.set('Unable to start sign-in. Please retry.');
} finally {
this.signingIn.set(false);
}
}
}

View File

@@ -79,7 +79,43 @@
<div class="stella-splash__bar-fill"></div>
</div>
</div>
<script>document.getElementById('stella-splash').dataset.ts=Date.now();</script>
<app-root></app-root>
</body>
</html>
<script>document.getElementById('stella-splash').dataset.ts=Date.now();</script>
<script>
(function () {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
window.__stellaWelcomePendingSignIn = false;
document.addEventListener(
'click',
function (event) {
if (!window.location.pathname.startsWith('/welcome')) {
return;
}
var target = event.target;
if (!(target instanceof Element)) {
return;
}
var button = target.closest('button.cta');
if (!button) {
return;
}
if (typeof window.__stellaWelcomeSignIn === 'function') {
return;
}
event.preventDefault();
window.__stellaWelcomePendingSignIn = true;
},
true
);
})();
</script>
<app-root></app-root>
</body>
</html>