feat(deploy): provision the web player client in development too

The web player's OIDC app had to be created by hand in the Zitadel console and
its client id copied into web-player/.env. provision.ps1 now creates both apps,
regenerates the client secret when it is no longer known, and wires the values
into deploy/.env, Musify.Api user-secrets and web-player/.env.

up.ps1 follows the new compose files, gains -Apps and -Tools for the two
profiles, generates the keys before starting rather than after, and no longer
swallows a provisioning failure as a warning.
This commit is contained in:
2026-07-20 15:14:00 +02:00
parent 7682de545d
commit b4385d9028
3 changed files with 184 additions and 102 deletions
+14 -3
View File
@@ -1,11 +1,11 @@
#requires -Version 5.1 #requires -Version 5.1
# Shared helpers for the deploy scripts. # Shared helpers for the development deploy scripts.
function Write-Step([string]$Message) { Write-Host "`n==> $Message" -ForegroundColor Cyan } function Write-Step([string]$Message) { Write-Host "`n==> $Message" -ForegroundColor Cyan }
# Parses a .env file into a hashtable (ignores comments and blank lines). # Parses a .env file into an ordered hashtable (ignores comments and blank lines).
function Read-DotEnv([string]$Path) { function Read-DotEnv([string]$Path) {
$values = @{} $values = [ordered]@{}
if (Test-Path $Path) { if (Test-Path $Path) {
Get-Content $Path | ForEach-Object { Get-Content $Path | ForEach-Object {
if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') { $values[$matches[1].Trim()] = $matches[2].Trim() } if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') { $values[$matches[1].Trim()] = $matches[2].Trim() }
@@ -13,3 +13,14 @@ function Read-DotEnv([string]$Path) {
} }
return $values return $values
} }
# Sets (or appends) a key in a .env file, preserving the rest of the file.
function Set-DotEnvValue([string]$Path, [string]$Key, [string]$Value) {
$lines = @(Get-Content $Path)
if ($lines -match "^\s*$Key=") {
$lines = $lines -replace "^\s*$Key=.*", "$Key=$Value"
} else {
$lines += "$Key=$Value"
}
Set-Content -Path $Path -Value $lines -Encoding utf8
}
+61 -38
View File
@@ -4,25 +4,35 @@
Brings up the Musify development environment. Brings up the Musify development environment.
.DESCRIPTION .DESCRIPTION
Starts the infrastructure stack, generates the stream-ticket keys, applies EF Starts the infrastructure stack, generates the stream-ticket keys, applies
Core migrations and provisions Zitadel (project + OIDC app + ClientId). the EF Core migrations and provisions Zitadel (project + OIDC apps). The
.NET apps and the web player then run from the IDE; add -Apps to run them
in Docker instead.
.EXAMPLE .EXAMPLE
./up.ps1 # Bring everything up ./up.ps1 # Infrastructure only (apps from the IDE)
./up.ps1 -Apps # Also build and run api, worker, gateway, web player
./up.ps1 -Tools # Also start pgAdmin
./up.ps1 -SkipMigrations # Skip the database migration step ./up.ps1 -SkipMigrations # Skip the database migration step
./up.ps1 -Down # Stop and remove containers (keep volumes) ./up.ps1 -Down # Stop and remove containers (keep volumes)
./up.ps1 -Destroy # Stop and remove containers + volumes (wipe data) ./up.ps1 -Destroy # Stop and remove containers + volumes (wipe data)
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
[switch]$Apps,
[switch]$Tools,
[switch]$SkipMigrations,
[switch]$Down, [switch]$Down,
[switch]$Destroy, [switch]$Destroy
[switch]$SkipMigrations
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$deployDir = $PSScriptRoot $deployDir = $PSScriptRoot
$composeFile = Join-Path $deployDir "docker-compose.yml" $compose = @(
"-f", (Join-Path $deployDir "compose.yml"),
"-f", (Join-Path $deployDir "compose.dev.yml")
)
$profiles = @("--profile", "apps", "--profile", "tools")
. (Join-Path $deployDir "scripts\common.ps1") . (Join-Path $deployDir "scripts\common.ps1")
@@ -30,12 +40,12 @@ $composeFile = Join-Path $deployDir "docker-compose.yml"
if ($Down -or $Destroy) { if ($Down -or $Destroy) {
if ($Destroy) { if ($Destroy) {
Write-Step "Stopping the stack and REMOVING volumes (data is wiped)" Write-Step "Stopping the stack and REMOVING volumes (data is wiped)"
docker compose -f $composeFile down -v --remove-orphans docker compose @compose @profiles down -v --remove-orphans
# Drop the Zitadel init secrets (PAT) so the next start re-initializes cleanly. # Drop the Zitadel init secrets so the next start re-initializes cleanly.
Get-ChildItem (Join-Path $deployDir "zitadel\.output") -File -ErrorAction SilentlyContinue | Remove-Item -Force Get-ChildItem (Join-Path $deployDir "zitadel\.output") -File -ErrorAction SilentlyContinue | Remove-Item -Force
} else { } else {
Write-Step "Stopping the stack (volumes are kept)" Write-Step "Stopping the stack (volumes are kept)"
docker compose -f $composeFile down --remove-orphans docker compose @compose @profiles down --remove-orphans
} }
return return
} }
@@ -47,12 +57,18 @@ if (-not (Test-Path $envFile)) {
Copy-Item (Join-Path $deployDir ".env.example") $envFile Copy-Item (Join-Path $deployDir ".env.example") $envFile
} }
$cfg = Read-DotEnv $envFile $cfg = Read-DotEnv $envFile
$streamKeysDir = if ($cfg['STREAM_KEYS_DIR']) { $cfg['STREAM_KEYS_DIR'] } else { "D:/weed" }
$workerTempDir = if ($cfg['WORKER_TEMP_DIR']) { $cfg['WORKER_TEMP_DIR'] } else { "D:/tempsFilesDev" } # Host directories the apps expect: RS256 keys (mounted into the api/gateway
# containers, read directly by the IDE runs) and the Worker scratch space.
& (Join-Path $deployDir "scripts\stream-keys.ps1") -KeysDir (Join-Path $deployDir "keys")
$tempDir = Join-Path $deployDir ".tmp"
if (-not (Test-Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir | Out-Null }
# Infrastructure ------------------------------------------------------------- # Infrastructure -------------------------------------------------------------
Write-Step "Starting the infrastructure stack" Write-Step "Starting the infrastructure stack"
docker compose -f $composeFile up -d $upArgs = @("up", "-d")
if ($Tools) { $upArgs = @("--profile", "tools") + $upArgs }
docker compose @compose @upArgs
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed" } if ($LASTEXITCODE -ne 0) { throw "docker compose up failed" }
Write-Step "Waiting for Postgres to become healthy" Write-Step "Waiting for Postgres to become healthy"
@@ -63,16 +79,6 @@ do {
} until ($status -eq "healthy" -or (Get-Date) -gt $deadline) } until ($status -eq "healthy" -or (Get-Date) -gt $deadline)
if ($status -ne "healthy") { throw "Postgres did not become healthy in time" } if ($status -ne "healthy") { throw "Postgres did not become healthy in time" }
# Stream-ticket keys ---------------------------------------------------------
Write-Step "Ensuring stream-ticket keys"
& (Join-Path $deployDir "scripts\stream-keys.ps1") -KeysDir $streamKeysDir
# Worker temp directory ------------------------------------------------------
if (-not (Test-Path $workerTempDir)) {
Write-Step "Creating the Worker temp directory: $workerTempDir"
New-Item -ItemType Directory -Path $workerTempDir | Out-Null
}
# Database migrations -------------------------------------------------------- # Database migrations --------------------------------------------------------
if ($SkipMigrations) { if ($SkipMigrations) {
Write-Host "`nSkipping migrations (-SkipMigrations)." Write-Host "`nSkipping migrations (-SkipMigrations)."
@@ -83,16 +89,36 @@ if ($SkipMigrations) {
} }
# Zitadel provisioning ------------------------------------------------------- # Zitadel provisioning -------------------------------------------------------
Write-Step "Provisioning Zitadel (project + OIDC app + ClientId)" # Writes the client ids into .env, Musify.Api user-secrets and web-player/.env,
try { # so it has to run before the app containers start.
& (Join-Path $deployDir "zitadel\provision.ps1") & (Join-Path $deployDir "zitadel\provision.ps1")
} catch {
Write-Host "Warning: Zitadel provisioning failed: $($_.Exception.Message)" -ForegroundColor Yellow # Applications ---------------------------------------------------------------
if ($Apps) {
Write-Step "Building and starting the applications"
docker compose @compose --profile apps up -d --build
if ($LASTEXITCODE -ne 0) { throw "docker compose up (apps) failed" }
} }
# Summary -------------------------------------------------------------------- # Summary --------------------------------------------------------------------
$zDomain = $cfg['ZITADEL_EXTERNAL_DOMAIN'] $cfg = Read-DotEnv $envFile
$zPort = $cfg['ZITADEL_EXTERNAL_PORT'] $authUrl = $cfg['PUBLIC_AUTH_URL']
$appLines = if ($Apps) {
@"
Musify API $($cfg['PUBLIC_API_URL'])/scalar/v1
Streaming gateway $($cfg['PUBLIC_STREAM_URL'])/health
Web player $($cfg['PUBLIC_WEB_URL'])
"@
} else {
@"
Run the apps from the IDE (or ./up.ps1 -Apps to run them in Docker):
dotnet run --project backend/Musify.Api # :5111
dotnet run --project backend/Musify.StreamingGateway # :8081
dotnet run --project backend/Musify.Worker # background
npm --prefix web-player run dev # :5173
"@
}
Write-Host @" Write-Host @"
============================================================ ============================================================
@@ -102,21 +128,18 @@ Write-Host @"
Service URL / port Service URL / port
------------------ -------------------------------------- ------------------ --------------------------------------
PostgreSQL localhost:$($cfg['POSTGRES_HOST_PORT']) (db: $($cfg['POSTGRES_DB'])) PostgreSQL localhost:$($cfg['POSTGRES_HOST_PORT']) (db: $($cfg['POSTGRES_DB']))
pgAdmin http://localhost:$($cfg['PGADMIN_HOST_PORT']) ($($cfg['PGADMIN_DEFAULT_EMAIL'])/$($cfg['PGADMIN_DEFAULT_PASSWORD'])) Zitadel console $authUrl/ui/console
Zitadel console http://$zDomain`:$zPort/ui/console
RabbitMQ mgmt http://localhost:15672 ($($cfg['RABBITMQ_DEFAULT_USER'])/$($cfg['RABBITMQ_DEFAULT_PASS'])) RabbitMQ mgmt http://localhost:15672 ($($cfg['RABBITMQ_DEFAULT_USER'])/$($cfg['RABBITMQ_DEFAULT_PASS']))
SeaweedFS S3 http://localhost:8333 (bucket: $($cfg['S3_BUCKET'])) SeaweedFS S3 http://localhost:8333 (bucket: $($cfg['S3_BUCKET']))
SeaweedFS filer http://localhost:8888 SeaweedFS filer http://localhost:8888
SeaweedFS master http://localhost:9333 SeaweedFS master http://localhost:9333
Jaeger traces http://localhost:16686 (OTLP: localhost:4317) Jaeger traces http://localhost:16686 (OTLP: localhost:4317)
pgAdmin (-Tools) http://localhost:$($cfg['PGADMIN_HOST_PORT']) ($($cfg['PGADMIN_DEFAULT_EMAIL'])/$($cfg['PGADMIN_DEFAULT_PASSWORD']))
Zitadel admin: $($cfg['ZITADEL_ADMIN_USERNAME'])@zitadel.$zDomain / $($cfg['ZITADEL_ADMIN_PASSWORD']) Zitadel admin $($cfg['ZITADEL_ADMIN_USERNAME'])@zitadel.$($cfg['ZITADEL_EXTERNAL_DOMAIN']) / $($cfg['ZITADEL_ADMIN_PASSWORD'])
Auth is provisioned automatically; ClientId is in Musify.Api user-secrets and .env. API client id $($cfg['AUTH_CLIENT_ID'])
Log in via Scalar: http://localhost:5111/scalar/v1 Web client id $($cfg['WEB_CLIENT_ID'])
Run the apps from the IDE or: $appLines
dotnet run --project backend/Musify.Api # :5111
dotnet run --project backend/Musify.StreamingGateway # :8081
dotnet run --project backend/Musify.Worker # background
============================================================ ============================================================
"@ -ForegroundColor Green "@ -ForegroundColor Green
+101 -53
View File
@@ -1,31 +1,40 @@
#requires -Version 5.1 #requires -Version 5.1
<# <#
.SYNOPSIS .SYNOPSIS
Provisions Zitadel for Musify (idempotent). Provisions Zitadel for Musify in development (idempotent).
.DESCRIPTION .DESCRIPTION
Creates the project and the OIDC app (PKCE, JWT access token) and wires the Creates the Musify project, the API OIDC app (public, PKCE, JWT access
generated ClientId into Musify.Api user-secrets and deploy/.env (AUTH_CLIENT_ID). token) and the web player OIDC app (confidential), then wires the resulting
Uses the service-account PAT that Zitadel writes on init (zitadel/.output/admin-sa.pat). client ids into deploy/.env, Musify.Api user-secrets and web-player/.env.
Re-running reuses the existing project/app and refreshes the wired values. Authenticates with the service-account PAT that Zitadel writes on init
(zitadel/.output/admin-sa.pat). Re-running reuses the existing project and
apps and just refreshes the wired values.
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
[string]$ProjectName = "Musify", [string]$ProjectName = "Musify",
[string]$AppName = "Musify API (Scalar)" [string]$ApiAppName = "Musify API (Scalar)",
[string]$WebAppName = "Musify Web"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$deployDir = Split-Path $PSScriptRoot -Parent $deployDir = Split-Path $PSScriptRoot -Parent
$apiProject = Resolve-Path (Join-Path $deployDir "..\backend\Musify.Api") $repoDir = Split-Path $deployDir -Parent
$apiProject = Join-Path $repoDir "backend\Musify.Api"
$webEnvFile = Join-Path $repoDir "web-player\.env"
$envFile = Join-Path $deployDir ".env"
$patFile = Join-Path $PSScriptRoot ".output\admin-sa.pat" $patFile = Join-Path $PSScriptRoot ".output\admin-sa.pat"
. (Join-Path $deployDir "scripts\common.ps1") . (Join-Path $deployDir "scripts\common.ps1")
$cfg = Read-DotEnv (Join-Path $deployDir ".env") $cfg = Read-DotEnv $envFile
$issuer = "http://$($cfg['ZITADEL_EXTERNAL_DOMAIN']):$($cfg['ZITADEL_EXTERNAL_PORT'])" $issuer = $cfg['PUBLIC_AUTH_URL']
$apiBase = "http://localhost:5111" $apiUrl = $cfg['PUBLIC_API_URL']
$scalarRedirect = "$apiBase/scalar/" $webUrl = $cfg['PUBLIC_WEB_URL']
$viteUrl = "http://localhost:5173" # `npm run dev` in web-player/
Write-Step "Provisioning Zitadel at $issuer"
# Zitadel writes the PAT during init; on a cold start it may take a moment. # Zitadel writes the PAT during init; on a cold start it may take a moment.
$deadline = (Get-Date).AddMinutes(3) $deadline = (Get-Date).AddMinutes(3)
@@ -41,7 +50,6 @@ function Invoke-Zitadel($method, $path, $body) {
Invoke-RestMethod @params Invoke-RestMethod @params
} }
Write-Step "Waiting for Zitadel at $issuer"
$deadline = (Get-Date).AddMinutes(2) $deadline = (Get-Date).AddMinutes(2)
do { do {
try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 | Out-Null; $ready = $true } try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 | Out-Null; $ready = $true }
@@ -49,86 +57,126 @@ do {
} until ($ready -or (Get-Date) -gt $deadline) } until ($ready -or (Get-Date) -gt $deadline)
if (-not $ready) { throw "Zitadel did not respond in time." } if (-not $ready) { throw "Zitadel did not respond in time." }
# Login UI (idempotent) ------------------------------------------------------ # The images tagged `latest` require the standalone Login UI v2 container, which
# The 'latest' Zitadel image requires the Login UI v2 (a separate 'login' # this stack does not run, so OIDC would redirect to /ui/v2/login and 404.
# container that this stack does not run), so OIDC would redirect to # Opting out keeps the built-in v1 login.
# /ui/v2/login and 404. Disable the requirement to use the built-in v1 login.
Write-Step "Disabling required Login UI v2 (use built-in /ui/login)"
try { try {
Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers ` Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers `
-ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 | Out-Null -ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 | Out-Null
Write-Host " loginV2.required = false"
} catch { } catch {
Write-Host " warning: could not update loginV2 feature: $($_.Exception.Message)" Write-Host " warning: could not disable the required Login UI v2: $($_.Exception.Message)"
} }
# Project (idempotent) ------------------------------------------------------- # Project --------------------------------------------------------------------
Write-Step "Project '$ProjectName'"
$project = (Invoke-Zitadel POST "/projects/_search" @{ $project = (Invoke-Zitadel POST "/projects/_search" @{
queries = @(@{ nameQuery = @{ name = $ProjectName; method = "TEXT_QUERY_METHOD_EQUALS" } }) queries = @(@{ nameQuery = @{ name = $ProjectName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $ProjectName } | Select-Object -First 1 }).result | Where-Object { $_.name -eq $ProjectName } | Select-Object -First 1
if ($project) { if ($project) {
$projectId = $project.id $projectId = $project.id
Write-Host " reused (id $projectId)" Write-Host " project '$ProjectName' reused (id $projectId)"
} else { } else {
$projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id $projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id
Write-Host " created (id $projectId)" Write-Host " project '$ProjectName' created (id $projectId)"
} }
# OIDC app (idempotent) ------------------------------------------------------ function Get-ZitadelApp($name) {
Write-Step "OIDC app '$AppName'" (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
$app = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{ queries = @(@{ nameQuery = @{ name = $name; method = "TEXT_QUERY_METHOD_EQUALS" } })
queries = @(@{ nameQuery = @{ name = $AppName; method = "TEXT_QUERY_METHOD_EQUALS" } }) }).result | Where-Object { $_.name -eq $name } | Select-Object -First 1
}).result | Where-Object { $_.name -eq $AppName } | Select-Object -First 1 }
if ($app) { # API app: public client used by the Scalar UI to obtain a token -------------
$clientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($app.id)" $null).app.oidcConfig.clientId $apiApp = Get-ZitadelApp $ApiAppName
Write-Host " reused (clientId $clientId)" if ($apiApp) {
$apiClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($apiApp.id)" $null).app.oidcConfig.clientId
Write-Host " app '$ApiAppName' reused (clientId $apiClientId)"
} else { } else {
$clientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{ $apiClientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $AppName name = $ApiAppName
redirectUris = @($scalarRedirect, "$apiBase/scalar/oauth2-redirect.html") redirectUris = @("$apiUrl/scalar/", "$apiUrl/scalar/oauth2-redirect.html")
postLogoutRedirectUris = @($scalarRedirect) postLogoutRedirectUris = @("$apiUrl/scalar/")
responseTypes = @("OIDC_RESPONSE_TYPE_CODE") responseTypes = @("OIDC_RESPONSE_TYPE_CODE")
grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE") grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE")
appType = "OIDC_APP_TYPE_USER_AGENT" appType = "OIDC_APP_TYPE_USER_AGENT"
authMethodType = "OIDC_AUTH_METHOD_TYPE_NONE" authMethodType = "OIDC_AUTH_METHOD_TYPE_NONE"
version = "OIDC_VERSION_1_0" version = "OIDC_VERSION_1_0"
devMode = $true # allow http redirect URIs devMode = $true # allow http redirect URIs
accessTokenType = "OIDC_TOKEN_TYPE_JWT" # required so JwtBearer can validate accessTokenType = "OIDC_TOKEN_TYPE_JWT" # required so JwtBearer can validate it
accessTokenRoleAssertion = $true accessTokenRoleAssertion = $true
idTokenRoleAssertion = $true idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true idTokenUserinfoAssertion = $true
}).clientId }).clientId
Write-Host " created (clientId $clientId)" Write-Host " app '$ApiAppName' created (clientId $apiClientId)"
} }
# Wire the config: user-secrets (local runs) + .env (docker runs) ------------- # Web app: confidential client used by the SvelteKit server ------------------
Write-Step "Wiring Authentication into Musify.Api user-secrets" $webApp = Get-ZitadelApp $WebAppName
$webSecret = $cfg['WEB_CLIENT_SECRET']
if ($webApp) {
$webClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($webApp.id)" $null).app.oidcConfig.clientId
# The secret is only returned once, so regenerate it if we no longer have it.
if (-not $webSecret) {
$webSecret = (Invoke-Zitadel POST "/projects/$projectId/apps/$($webApp.id)/oidc_config/_generate_client_secret" @{}).clientSecret
Write-Host " app '$WebAppName' reused (clientId $webClientId, secret regenerated)"
} else {
Write-Host " app '$WebAppName' reused (clientId $webClientId)"
}
} else {
$created = Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $WebAppName
redirectUris = @("$webUrl/auth/callback", "$viteUrl/auth/callback")
postLogoutRedirectUris = @("$webUrl/", "$viteUrl/")
responseTypes = @("OIDC_RESPONSE_TYPE_CODE")
grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN")
appType = "OIDC_APP_TYPE_WEB"
authMethodType = "OIDC_AUTH_METHOD_TYPE_BASIC"
version = "OIDC_VERSION_1_0"
devMode = $true
accessTokenType = "OIDC_TOKEN_TYPE_JWT"
accessTokenRoleAssertion = $true
idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true
}
$webClientId = $created.clientId
$webSecret = $created.clientSecret
Write-Host " app '$WebAppName' created (clientId $webClientId)"
}
# Wire the values ------------------------------------------------------------
Set-DotEnvValue $envFile "AUTH_CLIENT_ID" $apiClientId
Set-DotEnvValue $envFile "WEB_CLIENT_ID" $webClientId
Set-DotEnvValue $envFile "WEB_CLIENT_SECRET" $webSecret
# Musify.Api reads these from user-secrets when it runs outside Docker.
@{ @{
"Authentication:ClientId" = $clientId "Authentication:ClientId" = $apiClientId
"Authentication:AudienceAddress" = $clientId "Authentication:AudienceAddress" = $apiClientId
"Authentication:IssuerAddress" = $issuer "Authentication:IssuerAddress" = $issuer
"Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration" "Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration"
"Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login" "Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login"
"Authentication:TokenEndpoint" = "$issuer/oauth/v2/token" "Authentication:TokenEndpoint" = "$issuer/oauth/v2/token"
"Authentication:ScalarRedirectUri" = $scalarRedirect "Authentication:ScalarRedirectUri" = "$apiUrl/scalar/"
"Authentication:RequireHttpsMetadata" = "false" "Authentication:RequireHttpsMetadata" = "false"
}.GetEnumerator() | ForEach-Object { }.GetEnumerator() | ForEach-Object {
dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null
} }
$envPath = Join-Path $deployDir ".env" # web-player/.env is what `npm run dev` reads (the container gets its config
$envLines = Get-Content $envPath # from compose instead).
if ($envLines -match '^\s*AUTH_CLIENT_ID=') { if (-not (Test-Path $webEnvFile)) {
$envLines = $envLines -replace '^\s*AUTH_CLIENT_ID=.*', "AUTH_CLIENT_ID=$clientId" Copy-Item (Join-Path $repoDir "web-player\.env.example") $webEnvFile
} else { }
$envLines += "AUTH_CLIENT_ID=$clientId" @{
ZITADEL_ISSUER = $issuer
ZITADEL_CLIENT_ID = $webClientId
ZITADEL_CLIENT_SECRET = $webSecret
AUTH_REDIRECT_URI = "$viteUrl/auth/callback"
AUTH_POST_LOGOUT_URI = "$viteUrl/"
SESSION_SECRET = $cfg['SESSION_SECRET']
API_BASE_URL = $apiUrl
}.GetEnumerator() | ForEach-Object {
Set-DotEnvValue $webEnvFile $_.Key $_.Value
} }
Set-Content -Path $envPath -Value $envLines -Encoding utf8
Write-Host "`nZitadel provisioned:" -ForegroundColor Green Write-Host " wired into deploy/.env, Musify.Api user-secrets and web-player/.env"
Write-Host " Project: $ProjectName ($projectId)"
Write-Host " ClientId: $clientId"
Write-Host " Issuer: $issuer"