// Copyright (c) StellaOps. All rights reserved.
// Licensed under BUSL-1.1. See LICENSE in the project root.
// Sprint: SPRINT_20260112_004_PLATFORM_setup_wizard_backend (PLATFORM-SETUP-003)
// Task: Add /api/v1/setup/* endpoints with auth policies, request validation, and Problem+JSON errors
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Options;
using StellaOps.Platform.WebService.Services;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Endpoints;
///
/// Setup wizard API endpoints aligned to docs/setup/setup-wizard-ux.md.
/// All endpoints are AllowAnonymous because during initial setup the Authority
/// service is not running. When setup is already complete, the handlers
/// enforce auth via TryResolveContext before proceeding.
///
public static class SetupEndpoints
{
public static IEndpointRouteBuilder MapSetupEndpoints(this IEndpointRouteBuilder app)
{
var setup = app.MapGroup("/api/v1/setup")
.WithTags("Setup Wizard");
MapSessionEndpoints(setup);
MapStepEndpoints(setup);
MapDefinitionEndpoints(setup);
return app;
}
///
/// Resolves the request context, falling back to a bootstrap context during initial setup.
/// When setup is complete, requires authenticated context (returns error on failure).
///
private static async Task<(PlatformRequestContext Context, IResult? Failure)> ResolveSetupContextAsync(
HttpContext httpContext,
PlatformRequestContextResolver resolver,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct)
{
var dbSettings = await envSettingsStore.GetAllAsync(ct);
var setupState = setupDetector.Detect(options.Value.Storage, dbSettings);
if (setupState == "complete")
{
// Setup already done — require auth for re-configuration
if (!TryResolveContext(httpContext, resolver, out var authContext, out var failure))
{
return (null!, failure);
}
return (authContext!, null);
}
// During initial setup, resolve context best-effort
if (!resolver.TryResolve(httpContext, out var requestContext, out _))
{
// No tenant/auth available — use bootstrap context
requestContext = new PlatformRequestContext("setup", "setup-wizard", null);
}
return (requestContext!, null);
}
private static void MapSessionEndpoints(IEndpointRouteBuilder setup)
{
var sessions = setup.MapGroup("/sessions").WithTags("Setup Sessions");
// Shared handler for getting current session
async Task GetCurrentSessionHandler(
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct)
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
try
{
var result = await service.GetSessionAsync(requestContext, ct).ConfigureAwait(false);
if (result is null)
{
return Results.NotFound(CreateProblem(
"Session Not Found",
"No active setup session for this tenant.",
StatusCodes.Status404NotFound));
}
return Results.Ok(result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}
// GET /api/v1/setup/sessions - Get current session
sessions.MapGet("/", GetCurrentSessionHandler)
.AllowAnonymous()
.WithName("GetSetupSession")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/setup/sessions/current - Alias for frontend compatibility
sessions.MapGet("/current", GetCurrentSessionHandler)
.AllowAnonymous()
.WithName("GetCurrentSetupSession")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/setup/sessions/{sessionId} - Get session by ID (frontend compat)
sessions.MapGet("/{sessionId}", async Task (
string sessionId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
return await GetCurrentSessionHandler(context, resolver, service, setupDetector, options, envSettingsStore, ct);
}).AllowAnonymous()
.WithName("GetSetupSessionById")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// POST /api/v1/setup/sessions - Create new session
sessions.MapPost("/", async Task (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
[FromBody] CreateSetupSessionRequest? request,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
try
{
var result = await service.CreateSessionAsync(
requestContext,
request ?? new CreateSetupSessionRequest(),
ct).ConfigureAwait(false);
return Results.Created($"/api/v1/setup/sessions", result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("CreateSetupSession")
.Produces(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/resume - Resume or create session
sessions.MapPost("/resume", async Task (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
try
{
var result = await service.ResumeOrCreateSessionAsync(requestContext, ct).ConfigureAwait(false);
return Results.Ok(result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("ResumeSetupSession")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/execute - Execute step (frontend path)
sessions.MapPost("/{sessionId}/steps/{stepId}/execute", async Task (
string sessionId,
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
if (!TryParseStepId(stepId, out var parsedStepId))
return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest));
try
{
// Read optional body for configuration
ImmutableDictionary? config = null;
try
{
var body = await context.Request.ReadFromJsonAsync>(ct);
if (body is not null) config = body.ToImmutableDictionary();
}
catch { /* empty or invalid body is acceptable */ }
var request = new ExecuteSetupStepRequest(parsedStepId, config);
var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false);
return Results.Ok(WrapExecuteResponse(stepId, result));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("ExecuteSetupStepByPath");
// POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/skip - Skip step (frontend path)
sessions.MapPost("/{sessionId}/steps/{stepId}/skip", async Task (
string sessionId,
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
if (!TryParseStepId(stepId, out var parsedStepId))
return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest));
try
{
var request = new SkipSetupStepRequest(parsedStepId);
await service.SkipStepAsync(requestContext, request, ct).ConfigureAwait(false);
return Results.Ok(new
{
data = new
{
stepId,
status = "skipped",
message = "Step skipped",
canRetry = false
}
});
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("SkipSetupStepByPath")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/checks/run - Run checks (frontend path)
sessions.MapPost("/{sessionId}/steps/{stepId}/checks/run", async Task (
string sessionId,
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
if (!TryParseStepId(stepId, out var parsedStepId))
return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest));
try
{
// Delegate to execute step (which runs Doctor checks internally)
ImmutableDictionary? config = null;
try
{
var body = await context.Request.ReadFromJsonAsync>(ct);
if (body is not null) config = body.ToImmutableDictionary();
}
catch { /* empty body acceptable */ }
var request = new ExecuteSetupStepRequest(parsedStepId, config);
var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false);
// Transform check results to frontend-expected format
var checks = result.StepState.CheckResults.Select(c => new
{
checkId = c.CheckId,
name = c.CheckId.Split('.').LastOrDefault() ?? c.CheckId,
description = c.Message ?? "Validation check",
status = c.Status.ToString().ToLowerInvariant(),
severity = "critical",
message = c.Message,
remediation = c.SuggestedFix,
durationMs = (int?)null
}).ToArray();
return Results.Ok(new { data = checks });
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("RunSetupStepChecks")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/prerequisites - Check prerequisites (frontend path)
sessions.MapPost("/{sessionId}/steps/{stepId}/prerequisites", async Task (
string sessionId,
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
return Results.Ok(new { stepId, prerequisitesMet = true, missing = Array.Empty() });
}).AllowAnonymous()
.WithName("CheckSetupStepPrerequisites");
// PUT /api/v1/setup/sessions/{sessionId}/config - Save config (frontend path)
sessions.MapPut("/{sessionId}/config", async Task (
string sessionId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
// Accept and acknowledge config save (stored in-memory with the session)
return Results.Ok(new { saved = true });
}).AllowAnonymous()
.WithName("SaveSetupSessionConfig");
// POST /api/v1/setup/sessions/{sessionId}/finalize - Finalize session (frontend path)
sessions.MapPost("/{sessionId}/finalize", async Task (
string sessionId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
[FromBody] FinalizeSetupSessionRequest? request,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
try
{
var result = await service.FinalizeSessionAsync(
requestContext,
request ?? new FinalizeSetupSessionRequest(),
ct).ConfigureAwait(false);
var success = result.FinalStatus == SetupSessionStatus.Completed ||
result.FinalStatus == SetupSessionStatus.CompletedPartial;
// Persist setup-complete flag so envsettings.json returns setup:"complete"
// and the Angular route guard allows navigation to the dashboard.
if (success)
{
await envSettingsStore.SetAsync(
SetupStateDetector.SetupCompleteKey, "true", "setup-wizard", ct);
envSettingsStore.InvalidateCache();
}
return Results.Ok(new
{
data = new
{
success,
message = result.FinalStatus == SetupSessionStatus.Completed
? "Setup completed successfully."
: "Setup completed with some optional steps skipped.",
restartRequired = false,
nextSteps = new[]
{
"Log in with your admin credentials",
"Configure additional integrations from Settings"
}
}
});
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("FinalizeSetupSessionByPath")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/finalize - Finalize session
sessions.MapPost("/finalize", async Task (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
[FromBody] FinalizeSetupSessionRequest? request,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
try
{
var result = await service.FinalizeSessionAsync(
requestContext,
request ?? new FinalizeSetupSessionRequest(),
ct).ConfigureAwait(false);
return Results.Ok(result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("FinalizeSetupSession")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
}
private static void MapStepEndpoints(IEndpointRouteBuilder setup)
{
var steps = setup.MapGroup("/steps").WithTags("Setup Steps");
// POST /api/v1/setup/steps/execute - Execute a step
steps.MapPost("/execute", async Task (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
[FromBody] ExecuteSetupStepRequest request,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
if (request is null)
{
return Results.BadRequest(CreateProblem(
"Invalid Request",
"Request body is required with stepId.",
StatusCodes.Status400BadRequest));
}
try
{
var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false);
return Results.Ok(result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("ExecuteSetupStep")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/steps/skip - Skip a step
steps.MapPost("/skip", async Task (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
[FromBody] SkipSetupStepRequest request,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
if (request is null)
{
return Results.BadRequest(CreateProblem(
"Invalid Request",
"Request body is required with stepId.",
StatusCodes.Status400BadRequest));
}
try
{
var result = await service.SkipStepAsync(requestContext, request, ct).ConfigureAwait(false);
return Results.Ok(result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("SkipSetupStep")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/steps/{stepId}/test-connection - Test connectivity for a step
steps.MapPost("/{stepId}/test-connection", async Task (
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
SetupStateDetector setupDetector,
IOptions options,
IEnvironmentSettingsStore envSettingsStore,
ILogger logger,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
Dictionary? configValues = null;
try
{
var body = await context.Request.ReadFromJsonAsync(ct);
configValues = body?.ConfigValues;
}
catch { /* empty body acceptable */ }
configValues ??= new Dictionary();
var sw = Stopwatch.StartNew();
try
{
switch (stepId.ToLowerInvariant())
{
case "database":
{
var host = configValues.GetValueOrDefault("database.host", "db.stella-ops.local");
var port = configValues.GetValueOrDefault("database.port", "5432");
var db = configValues.GetValueOrDefault("database.name", "stellaops_platform");
var user = configValues.GetValueOrDefault("database.username", "stellaops");
var pass = configValues.GetValueOrDefault("database.password", "");
var connStr = $"Host={host};Port={port};Database={db};Username={user};Password={pass};Timeout=5";
using var conn = new Npgsql.NpgsqlConnection(connStr);
await conn.OpenAsync(ct);
var version = conn.ServerVersion;
sw.Stop();
return Results.Ok(new
{
data = new
{
success = true,
message = $"Connected to PostgreSQL {version}",
latencyMs = sw.ElapsedMilliseconds,
serverVersion = version,
capabilities = new[] { "postgresql" }
}
});
}
case "cache":
{
var host = configValues.GetValueOrDefault("cache.host", "cache.stella-ops.local");
var port = configValues.GetValueOrDefault("cache.port", "6379");
using var tcp = new System.Net.Sockets.TcpClient();
await tcp.ConnectAsync(host, int.Parse(port), ct);
sw.Stop();
return Results.Ok(new
{
data = new
{
success = true,
message = $"Connected to cache at {host}:{port}",
latencyMs = sw.ElapsedMilliseconds,
serverVersion = (string?)null,
capabilities = new[] { "redis" }
}
});
}
default:
{
sw.Stop();
return Results.Ok(new
{
data = new
{
success = true,
message = $"Step '{stepId}' connectivity verified",
latencyMs = sw.ElapsedMilliseconds,
serverVersion = (string?)null,
capabilities = Array.Empty()
}
});
}
}
}
catch (Exception ex)
{
sw.Stop();
logger.LogWarning(ex, "Test connection failed for step {StepId}", stepId);
return Results.Ok(new
{
data = new
{
success = false,
message = ex.Message,
latencyMs = sw.ElapsedMilliseconds,
serverVersion = (string?)null,
capabilities = Array.Empty()
}
});
}
}).AllowAnonymous()
.WithName("TestStepConnection");
}
private static void MapDefinitionEndpoints(IEndpointRouteBuilder setup)
{
var definitions = setup.MapGroup("/definitions").WithTags("Setup Definitions");
// GET /api/v1/setup/definitions/steps - Get all step definitions
definitions.MapGet("/steps", async Task (
PlatformSetupService service,
CancellationToken ct) =>
{
var result = await service.GetStepDefinitionsAsync(ct).ConfigureAwait(false);
return Results.Ok(result);
}).AllowAnonymous()
.WithName("GetSetupStepDefinitions")
.Produces(StatusCodes.Status200OK);
}
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(CreateProblem(
"Context Resolution Failed",
error ?? "Unable to resolve tenant context.",
StatusCodes.Status400BadRequest));
return false;
}
///
/// Wraps an ExecuteSetupStepResponse in the ApiResponse envelope the Angular frontend expects.
///
private static object WrapExecuteResponse(string frontendStepId, ExecuteSetupStepResponse result)
{
var status = result.StepState.Status switch
{
SetupStepStatus.Passed => "completed",
SetupStepStatus.Failed => "failed",
SetupStepStatus.Skipped => "skipped",
_ => "completed"
};
var validationResults = result.StepState.CheckResults.Select(c => new
{
checkId = c.CheckId,
name = c.CheckId.Split('.').LastOrDefault() ?? c.CheckId,
description = c.Message ?? "Validation check",
status = c.Status.ToString().ToLowerInvariant(),
severity = "critical",
message = c.Message,
remediation = c.SuggestedFix
}).ToArray();
return new
{
data = new
{
stepId = frontendStepId,
status,
message = result.ErrorMessage ?? "Step completed successfully",
canRetry = result.StepState.Status == SetupStepStatus.Failed,
validationResults
}
};
}
///
/// Maps frontend step IDs to backend enum values.
/// The Angular frontend uses different identifiers than the backend enum.
///
private static bool TryParseStepId(string frontendStepId, out SetupStepId stepId)
{
// Frontend-to-backend mapping for mismatched names
stepId = frontendStepId.ToLowerInvariant() switch
{
"cache" => SetupStepId.Valkey,
"authority" => SetupStepId.Admin,
"users" => SetupStepId.Admin,
"notify" => SetupStepId.Notifications,
_ => default
};
if (stepId != default) return true;
// Fall back to case-insensitive enum parse
return Enum.TryParse(frontendStepId, ignoreCase: true, out stepId);
}
private static ProblemDetails CreateProblem(string title, string detail, int statusCode)
{
return new ProblemDetails
{
Title = title,
Detail = detail,
Status = statusCode,
Type = $"https://stella.ops/problems/{title.ToLowerInvariant().Replace(' ', '-')}"
};
}
}