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
# Shared helpers for the deploy scripts.
# Shared helpers for the development deploy scripts.
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) {
$values = @{}
$values = [ordered]@{}
if (Test-Path $Path) {
Get-Content $Path | ForEach-Object {
if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') { $values[$matches[1].Trim()] = $matches[2].Trim() }
@@ -13,3 +13,14 @@ function Read-DotEnv([string]$Path) {
}
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
}
+60 -37
View File
@@ -4,25 +4,35 @@
Brings up the Musify development environment.
.DESCRIPTION
Starts the infrastructure stack, generates the stream-ticket keys, applies EF
Core migrations and provisions Zitadel (project + OIDC app + ClientId).
Starts the infrastructure stack, generates the stream-ticket keys, applies
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
./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 -Down # Stop and remove containers (keep volumes)
./up.ps1 -Destroy # Stop and remove containers + volumes (wipe data)
#>
[CmdletBinding()]
param(
[switch]$Apps,
[switch]$Tools,
[switch]$SkipMigrations,
[switch]$Down,
[switch]$Destroy,
[switch]$SkipMigrations
[switch]$Destroy
)
$ErrorActionPreference = "Stop"
$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")
@@ -30,12 +40,12 @@ $composeFile = Join-Path $deployDir "docker-compose.yml"
if ($Down -or $Destroy) {
if ($Destroy) {
Write-Step "Stopping the stack and REMOVING volumes (data is wiped)"
docker compose -f $composeFile down -v --remove-orphans
# Drop the Zitadel init secrets (PAT) so the next start re-initializes cleanly.
docker compose @compose @profiles down -v --remove-orphans
# 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
} else {
Write-Step "Stopping the stack (volumes are kept)"
docker compose -f $composeFile down --remove-orphans
docker compose @compose @profiles down --remove-orphans
}
return
}
@@ -47,12 +57,18 @@ if (-not (Test-Path $envFile)) {
Copy-Item (Join-Path $deployDir ".env.example") $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 -------------------------------------------------------------
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" }
Write-Step "Waiting for Postgres to become healthy"
@@ -63,16 +79,6 @@ do {
} until ($status -eq "healthy" -or (Get-Date) -gt $deadline)
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 --------------------------------------------------------
if ($SkipMigrations) {
Write-Host "`nSkipping migrations (-SkipMigrations)."
@@ -83,16 +89,36 @@ if ($SkipMigrations) {
}
# Zitadel provisioning -------------------------------------------------------
Write-Step "Provisioning Zitadel (project + OIDC app + ClientId)"
try {
# Writes the client ids into .env, Musify.Api user-secrets and web-player/.env,
# so it has to run before the app containers start.
& (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 --------------------------------------------------------------------
$zDomain = $cfg['ZITADEL_EXTERNAL_DOMAIN']
$zPort = $cfg['ZITADEL_EXTERNAL_PORT']
$cfg = Read-DotEnv $envFile
$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 @"
============================================================
@@ -102,21 +128,18 @@ Write-Host @"
Service URL / port
------------------ --------------------------------------
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 http://$zDomain`:$zPort/ui/console
Zitadel console $authUrl/ui/console
RabbitMQ mgmt http://localhost:15672 ($($cfg['RABBITMQ_DEFAULT_USER'])/$($cfg['RABBITMQ_DEFAULT_PASS']))
SeaweedFS S3 http://localhost:8333 (bucket: $($cfg['S3_BUCKET']))
SeaweedFS filer http://localhost:8888
SeaweedFS master http://localhost:9333
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'])
Auth is provisioned automatically; ClientId is in Musify.Api user-secrets and .env.
Log in via Scalar: http://localhost:5111/scalar/v1
Zitadel admin $($cfg['ZITADEL_ADMIN_USERNAME'])@zitadel.$($cfg['ZITADEL_EXTERNAL_DOMAIN']) / $($cfg['ZITADEL_ADMIN_PASSWORD'])
API client id $($cfg['AUTH_CLIENT_ID'])
Web client id $($cfg['WEB_CLIENT_ID'])
Run the apps from the IDE or:
dotnet run --project backend/Musify.Api # :5111
dotnet run --project backend/Musify.StreamingGateway # :8081
dotnet run --project backend/Musify.Worker # background
$appLines
============================================================
"@ -ForegroundColor Green
+101 -53
View File
@@ -1,31 +1,40 @@
#requires -Version 5.1
<#
.SYNOPSIS
Provisions Zitadel for Musify (idempotent).
Provisions Zitadel for Musify in development (idempotent).
.DESCRIPTION
Creates the project and the OIDC app (PKCE, JWT access token) and wires the
generated ClientId into Musify.Api user-secrets and deploy/.env (AUTH_CLIENT_ID).
Uses the service-account PAT that Zitadel writes on init (zitadel/.output/admin-sa.pat).
Re-running reuses the existing project/app and refreshes the wired values.
Creates the Musify project, the API OIDC app (public, PKCE, JWT access
token) and the web player OIDC app (confidential), then wires the resulting
client ids into deploy/.env, Musify.Api user-secrets and web-player/.env.
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()]
param(
[string]$ProjectName = "Musify",
[string]$AppName = "Musify API (Scalar)"
[string]$ApiAppName = "Musify API (Scalar)",
[string]$WebAppName = "Musify Web"
)
$ErrorActionPreference = "Stop"
$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"
. (Join-Path $deployDir "scripts\common.ps1")
$cfg = Read-DotEnv (Join-Path $deployDir ".env")
$issuer = "http://$($cfg['ZITADEL_EXTERNAL_DOMAIN']):$($cfg['ZITADEL_EXTERNAL_PORT'])"
$apiBase = "http://localhost:5111"
$scalarRedirect = "$apiBase/scalar/"
$cfg = Read-DotEnv $envFile
$issuer = $cfg['PUBLIC_AUTH_URL']
$apiUrl = $cfg['PUBLIC_API_URL']
$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.
$deadline = (Get-Date).AddMinutes(3)
@@ -41,7 +50,6 @@ function Invoke-Zitadel($method, $path, $body) {
Invoke-RestMethod @params
}
Write-Step "Waiting for Zitadel at $issuer"
$deadline = (Get-Date).AddMinutes(2)
do {
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)
if (-not $ready) { throw "Zitadel did not respond in time." }
# Login UI (idempotent) ------------------------------------------------------
# The 'latest' Zitadel image requires the Login UI v2 (a separate 'login'
# container that this stack does not run), so OIDC would redirect to
# /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)"
# The images tagged `latest` require the standalone Login UI v2 container, which
# this stack does not run, so OIDC would redirect to /ui/v2/login and 404.
# Opting out keeps the built-in v1 login.
try {
Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers `
-ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 | Out-Null
Write-Host " loginV2.required = false"
} 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) -------------------------------------------------------
Write-Step "Project '$ProjectName'"
# Project --------------------------------------------------------------------
$project = (Invoke-Zitadel POST "/projects/_search" @{
queries = @(@{ nameQuery = @{ name = $ProjectName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $ProjectName } | Select-Object -First 1
if ($project) {
$projectId = $project.id
Write-Host " reused (id $projectId)"
Write-Host " project '$ProjectName' reused (id $projectId)"
} else {
$projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id
Write-Host " created (id $projectId)"
Write-Host " project '$ProjectName' created (id $projectId)"
}
# OIDC app (idempotent) ------------------------------------------------------
Write-Step "OIDC app '$AppName'"
$app = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
queries = @(@{ nameQuery = @{ name = $AppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $AppName } | Select-Object -First 1
function Get-ZitadelApp($name) {
(Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
queries = @(@{ nameQuery = @{ name = $name; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $name } | Select-Object -First 1
}
if ($app) {
$clientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($app.id)" $null).app.oidcConfig.clientId
Write-Host " reused (clientId $clientId)"
# API app: public client used by the Scalar UI to obtain a token -------------
$apiApp = Get-ZitadelApp $ApiAppName
if ($apiApp) {
$apiClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($apiApp.id)" $null).app.oidcConfig.clientId
Write-Host " app '$ApiAppName' reused (clientId $apiClientId)"
} else {
$clientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $AppName
redirectUris = @($scalarRedirect, "$apiBase/scalar/oauth2-redirect.html")
postLogoutRedirectUris = @($scalarRedirect)
$apiClientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $ApiAppName
redirectUris = @("$apiUrl/scalar/", "$apiUrl/scalar/oauth2-redirect.html")
postLogoutRedirectUris = @("$apiUrl/scalar/")
responseTypes = @("OIDC_RESPONSE_TYPE_CODE")
grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE")
appType = "OIDC_APP_TYPE_USER_AGENT"
authMethodType = "OIDC_AUTH_METHOD_TYPE_NONE"
version = "OIDC_VERSION_1_0"
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
idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true
}).clientId
Write-Host " created (clientId $clientId)"
Write-Host " app '$ApiAppName' created (clientId $apiClientId)"
}
# Wire the config: user-secrets (local runs) + .env (docker runs) -------------
Write-Step "Wiring Authentication into Musify.Api user-secrets"
# Web app: confidential client used by the SvelteKit server ------------------
$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:AudienceAddress" = $clientId
"Authentication:ClientId" = $apiClientId
"Authentication:AudienceAddress" = $apiClientId
"Authentication:IssuerAddress" = $issuer
"Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration"
"Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login"
"Authentication:TokenEndpoint" = "$issuer/oauth/v2/token"
"Authentication:ScalarRedirectUri" = $scalarRedirect
"Authentication:ScalarRedirectUri" = "$apiUrl/scalar/"
"Authentication:RequireHttpsMetadata" = "false"
}.GetEnumerator() | ForEach-Object {
dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null
}
$envPath = Join-Path $deployDir ".env"
$envLines = Get-Content $envPath
if ($envLines -match '^\s*AUTH_CLIENT_ID=') {
$envLines = $envLines -replace '^\s*AUTH_CLIENT_ID=.*', "AUTH_CLIENT_ID=$clientId"
} else {
$envLines += "AUTH_CLIENT_ID=$clientId"
# web-player/.env is what `npm run dev` reads (the container gets its config
# from compose instead).
if (-not (Test-Path $webEnvFile)) {
Copy-Item (Join-Path $repoDir "web-player\.env.example") $webEnvFile
}
@{
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 " Project: $ProjectName ($projectId)"
Write-Host " ClientId: $clientId"
Write-Host " Issuer: $issuer"
Write-Host " wired into deploy/.env, Musify.Api user-secrets and web-player/.env"