Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
7 changed files with 328 additions and 181 deletions
Showing only changes of commit 7682de545d - Show all commits
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Shared helpers for the production deploy scripts.
step() { printf '\n==> %s\n' "$1"; }
fail() { printf 'error: %s\n' "$1" >&2; exit 1; }
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "$1 is required but not installed."
}
# env_get <file> <key> — prints the value, empty if the key is absent.
env_get() {
grep -E "^$2=" "$1" 2>/dev/null | tail -n 1 | cut -d= -f2-
}
# env_set <file> <key> <value> — replaces the key in place, appends if missing.
env_set() {
_file=$1; _key=$2; _value=$3
_tmp=$(mktemp)
if grep -qE "^$_key=" "$_file"; then
awk -v k="$_key" -v v="$_value" '
index($0, k "=") == 1 { print k "=" v; next }
{ print }
' "$_file" >"$_tmp"
else
cp "$_file" "$_tmp"
printf '%s=%s\n' "$_key" "$_value" >>"$_tmp"
fi
cat "$_tmp" >"$_file"
rm -f "$_tmp"
}
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
# Applies EF Core migrations to the prod Postgres, from a throwaway SDK
# container attached to the compose network (postgres is not published to the host).
set -eu
DEPLOY_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
BACKEND_DIR="$(CDPATH= cd -- "$DEPLOY_DIR/../backend" && pwd)"
ENV_FILE="$DEPLOY_DIR/.env.prod"
. "$ENV_FILE"
NETWORK="musify-prod_default"
CONN="Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
docker run --rm \
--network "$NETWORK" \
-v "$BACKEND_DIR":/src \
-w /src/Musify.Infrastructure \
mcr.microsoft.com/dotnet/sdk:10.0 \
sh -c "dotnet tool install --global dotnet-ef >/dev/null 2>&1 || true; \
export PATH=\$PATH:/root/.dotnet/tools; \
dotnet restore Musify.Infrastructure.csproj; \
dotnet user-secrets set 'Database:ConnectionString' '$CONN' >/dev/null; \
dotnet ef database update --context Database"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Applies the EF Core migrations to the production database from a throwaway SDK
# container attached to the compose network (Postgres is not published to the host).
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DEPLOY_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
BACKEND_DIR=$(CDPATH= cd -- "$DEPLOY_DIR/../backend" && pwd)
ENV_FILE="$DEPLOY_DIR/.env.prod"
NETWORK=${MUSIFY_NETWORK:-musify-prod_default}
. "$SCRIPT_DIR/lib.sh"
CONN="Host=postgres;Port=5432;Database=$(env_get "$ENV_FILE" POSTGRES_DB);Username=$(env_get "$ENV_FILE" POSTGRES_USER);Password=$(env_get "$ENV_FILE" POSTGRES_PASSWORD)"
# Musify.Infrastructure is both the migrations and the startup project: it owns
# the IDesignTimeDbContextFactory and the EFCore.Design package.
docker run --rm \
--network "$NETWORK" \
-v "$BACKEND_DIR":/src \
-w /src/Musify.Infrastructure \
-e CONN="$CONN" \
mcr.microsoft.com/dotnet/sdk:10.0 \
sh -c 'dotnet tool install --global dotnet-ef >/dev/null 2>&1 || true
export PATH=$PATH:/root/.dotnet/tools
dotnet restore Musify.Infrastructure.csproj
dotnet user-secrets set "Database:ConnectionString" "$CONN" >/dev/null
dotnet ef database update --context Database'
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Generates the RS256 key pair used to sign (API) and verify (Gateway) stream
# tickets. No-op if both keys already exist.
set -eu
KEYS_DIR=${1:?usage: stream-keys.sh <keys-dir>}
PRIVATE_KEY="$KEYS_DIR/stream_private.pem"
PUBLIC_KEY="$KEYS_DIR/stream_public.pem"
if [ -f "$PRIVATE_KEY" ] && [ -f "$PUBLIC_KEY" ]; then
echo "Stream-ticket keys already present in $KEYS_DIR"
exit 0
fi
mkdir -p "$KEYS_DIR"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$PRIVATE_KEY" 2>/dev/null
openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" 2>/dev/null
chmod 644 "$PUBLIC_KEY"
chmod 600 "$PRIVATE_KEY"
echo "Generated stream-ticket keys in $KEYS_DIR"
+103
View File
@@ -0,0 +1,103 @@
#!/bin/sh
# Brings up (or updates) the Musify production stack on the server.
#
# ./up.sh build + start everything, provision Zitadel, migrate
# ./up.sh --no-build same, reusing the images that are already built
# ./up.sh --down stop and remove the containers (volumes are kept)
#
# Everything it needs lives in deploy/.env.prod (see .env.prod.example) and in
# deploy/nginx/certs/ (origin.pem + origin.key).
set -eu
DEPLOY_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ENV_FILE="$DEPLOY_DIR/.env.prod"
INFRA="postgres zitadel rabbitmq seaweedfs seaweedfs-init jaeger"
. "$DEPLOY_DIR/scripts/lib.sh"
require_command docker
require_command openssl
compose() {
docker compose --env-file "$ENV_FILE" \
-f "$DEPLOY_DIR/compose.yml" -f "$DEPLOY_DIR/compose.prod.yml" \
--profile apps "$@"
}
BUILD=--build
for arg in "$@"; do
case "$arg" in
--no-build) BUILD= ;;
--down) step "Stopping the stack (volumes are kept)"; compose down --remove-orphans; exit 0 ;;
*) fail "unknown option: $arg" ;;
esac
done
# Preflight ------------------------------------------------------------------
if [ ! -f "$ENV_FILE" ]; then
cp "$DEPLOY_DIR/.env.prod.example" "$ENV_FILE"
chmod 600 "$ENV_FILE"
fail "created $ENV_FILE from the template — fill it in (domain + secrets) and run again."
fi
if grep -q 'CHANGE_ME\|example\.com' "$ENV_FILE"; then
fail "$ENV_FILE still contains CHANGE_ME or example.com placeholders."
fi
for cert in origin.pem origin.key; do
[ -f "$DEPLOY_DIR/nginx/certs/$cert" ] \
|| fail "missing nginx/certs/$cert (Cloudflare Origin Certificate)."
done
mkdir -p "$DEPLOY_DIR/secrets" "$DEPLOY_DIR/keys" "$DEPLOY_DIR/zitadel/.output"
case "$(env_get "$ENV_FILE" YTDLP_ADDITIONAL_ARGUMENTS)" in
*--cookies*)
[ -f "$DEPLOY_DIR/secrets/youtube_cookies.txt" ] \
|| echo "warning: YTDLP_ADDITIONAL_ARGUMENTS expects secrets/youtube_cookies.txt, which is missing." ;;
esac
step "Ensuring stream-ticket keys"
sh "$DEPLOY_DIR/scripts/stream-keys.sh" "$DEPLOY_DIR/keys"
# Infrastructure -------------------------------------------------------------
step "Starting the infrastructure"
# shellcheck disable=SC2086
compose up -d $INFRA
step "Waiting for Postgres"
deadline=$(( $(date +%s) + 120 ))
until [ "$(docker inspect --format '{{.State.Health.Status}}' musify-postgres 2>/dev/null)" = healthy ]; do
[ "$(date +%s)" -lt "$deadline" ] || fail "Postgres did not become healthy in time."
sleep 2
done
# nginx has to be up before provisioning: it terminates TLS for the Zitadel
# domain. --no-deps keeps the apps from starting before they have a client id.
step "Starting the edge"
compose up -d --no-deps nginx
# Zitadel + database ---------------------------------------------------------
sh "$DEPLOY_DIR/zitadel/provision.sh"
step "Applying EF Core migrations"
sh "$DEPLOY_DIR/scripts/migrate.sh"
# Applications ---------------------------------------------------------------
step "Starting the applications"
# shellcheck disable=SC2086
compose up -d $BUILD
step "Done"
compose ps
cat <<EOF
Web player $(env_get "$ENV_FILE" PUBLIC_WEB_URL)
API $(env_get "$ENV_FILE" PUBLIC_API_URL)/scalar/v1
Streaming $(env_get "$ENV_FILE" PUBLIC_STREAM_URL)/health
Zitadel $(env_get "$ENV_FILE" PUBLIC_AUTH_URL)/ui/console
$(env_get "$ENV_FILE" ZITADEL_ADMIN_USERNAME)@zitadel.$(env_get "$ENV_FILE" ZITADEL_EXTERNAL_DOMAIN)
Jaeger collects traces but publishes no port. To look at them, forward its
container address over SSH from your machine:
ssh -L 16686:\$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' musify-jaeger):16686 <server>
EOF
-157
View File
@@ -1,157 +0,0 @@
#requires -Version 7.0
<#
.SYNOPSIS
Provisions Zitadel for Musify in production (idempotent).
.DESCRIPTION
Same idea as provision.ps1 but for the VPS deploy: creates the project, the
API OIDC app (PKCE, JWT) and the Web OIDC app (confidential, JWT), then
wires the values into deploy/.env.prod. Run from deploy/zitadel/ on the VPS
after `docker compose ... up -d` has brought zitadel up.
#>
[CmdletBinding()]
param(
[string]$ProjectName = "Musify",
[string]$ApiAppName = "Musify API",
[string]$WebAppName = "Musify Web"
)
$ErrorActionPreference = "Stop"
$deployDir = Split-Path $PSScriptRoot -Parent
$envPath = Join-Path $deployDir ".env.prod"
$patFile = Join-Path $PSScriptRoot ".output/admin-sa.pat"
function Read-DotEnv($path) {
$map = @{}
Get-Content $path | ForEach-Object {
if ($_ -match '^\s*#' -or $_ -notmatch '=') { return }
$k, $v = $_.Split('=', 2)
$map[$k.Trim()] = $v.Trim()
}
return $map
}
$cfg = Read-DotEnv $envPath
$issuer = "https://$($cfg['AUTH_DOMAIN'])"
$apiUrl = "https://$($cfg['API_DOMAIN'])"
$webUrl = "https://$($cfg['WEB_DOMAIN'])"
$deadline = (Get-Date).AddMinutes(3)
while (-not (Test-Path $patFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 }
if (-not (Test-Path $patFile)) { throw "PAT not found ($patFile). Check 'docker compose logs zitadel'." }
$headers = @{ Authorization = "Bearer $((Get-Content $patFile -Raw).Trim())" }
$mgmt = "$issuer/management/v1"
function Invoke-Zitadel($method, $path, $body) {
$params = @{ Method = $method; Uri = "$mgmt$path"; Headers = $headers; ContentType = "application/json"; TimeoutSec = 20 }
if ($body) { $params.Body = ($body | ConvertTo-Json -Depth 10) }
Invoke-RestMethod @params -SkipCertificateCheck
}
Write-Host "Waiting for Zitadel at $issuer"
$deadline = (Get-Date).AddMinutes(2)
do {
try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 -SkipCertificateCheck | Out-Null; $ready = $true }
catch { $ready = $false; Start-Sleep 3 }
} until ($ready -or (Get-Date) -gt $deadline)
if (-not $ready) { throw "Zitadel did not respond in time." }
Write-Host "Disabling required Login UI v2 (use built-in /ui/login)"
try {
Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers `
-ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 -SkipCertificateCheck | Out-Null
} catch {
Write-Host " warning: could not update loginV2 feature: $($_.Exception.Message)"
}
Write-Host "Project '$ProjectName'"
$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)"
} else {
$projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id
Write-Host " created (id $projectId)"
}
Write-Host "OIDC app '$ApiAppName' (public, PKCE)"
$apiApp = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
queries = @(@{ nameQuery = @{ name = $ApiAppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $ApiAppName } | Select-Object -First 1
if ($apiApp) {
$apiClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($apiApp.id)" $null).app.oidcConfig.clientId
Write-Host " reused (clientId $apiClientId)"
} else {
$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 = $false
accessTokenType = "OIDC_TOKEN_TYPE_JWT"
accessTokenRoleAssertion = $true
idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true
}).clientId
Write-Host " created (clientId $apiClientId)"
}
Write-Host "OIDC app '$WebAppName' (confidential)"
$webApp = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
queries = @(@{ nameQuery = @{ name = $WebAppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $WebAppName } | Select-Object -First 1
$webClientSecret = $null
if ($webApp) {
$webClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($webApp.id)" $null).app.oidcConfig.clientId
Write-Host " reused (clientId $webClientId, secret NOT regenerated)"
} else {
$created = Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $WebAppName
redirectUris = @("$webUrl/auth/callback")
postLogoutRedirectUris = @("$webUrl/")
responseTypes = @("OIDC_RESPONSE_TYPE_CODE")
grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE")
appType = "OIDC_APP_TYPE_WEB"
authMethodType = "OIDC_AUTH_METHOD_TYPE_BASIC"
version = "OIDC_VERSION_1_0"
devMode = $false
accessTokenType = "OIDC_TOKEN_TYPE_JWT"
accessTokenRoleAssertion = $true
idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true
}
$webClientId = $created.clientId
$webClientSecret = $created.clientSecret
Write-Host " created (clientId $webClientId)"
}
$envLines = Get-Content $envPath
function Set-EnvValue([string[]]$lines, [string]$key, [string]$value) {
if ($lines -match "^\s*$key=") {
return $lines -replace "^\s*$key=.*", "$key=$value"
}
return $lines + "$key=$value"
}
$envLines = Set-EnvValue $envLines "AUTH_CLIENT_ID" $apiClientId
$envLines = Set-EnvValue $envLines "WEB_CLIENT_ID" $webClientId
if ($webClientSecret) {
$envLines = Set-EnvValue $envLines "WEB_CLIENT_SECRET" $webClientSecret
}
Set-Content -Path $envPath -Value $envLines -Encoding utf8
Write-Host "`nZitadel provisioned:" -ForegroundColor Green
Write-Host " Project: $ProjectName ($projectId)"
Write-Host " API app: $apiClientId"
Write-Host " Web app: $webClientId"
Write-Host " Issuer: $issuer"
if ($webClientSecret) { Write-Host " Web app secret written to .env.prod (WEB_CLIENT_SECRET)" }
+145
View File
@@ -0,0 +1,145 @@
#!/bin/sh
# Provisions Zitadel for Musify in production (idempotent).
#
# Creates the Musify project, the API OIDC app (public, PKCE, JWT access token)
# and the web player OIDC app (confidential), then writes AUTH_CLIENT_ID,
# WEB_CLIENT_ID and WEB_CLIENT_SECRET into deploy/.env.prod. Authenticates with
# the service-account PAT that Zitadel writes on init. Re-running reuses the
# existing project and apps.
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DEPLOY_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
ENV_FILE="$DEPLOY_DIR/.env.prod"
PAT_FILE="$SCRIPT_DIR/.output/admin-sa.pat"
. "$DEPLOY_DIR/scripts/lib.sh"
require_command curl
require_command jq
PROJECT_NAME=${PROJECT_NAME:-Musify}
API_APP_NAME=${API_APP_NAME:-Musify API}
WEB_APP_NAME=${WEB_APP_NAME:-Musify Web}
ISSUER=$(env_get "$ENV_FILE" PUBLIC_AUTH_URL)
API_URL=$(env_get "$ENV_FILE" PUBLIC_API_URL)
WEB_URL=$(env_get "$ENV_FILE" PUBLIC_WEB_URL)
MGMT="$ISSUER/management/v1"
step "Provisioning Zitadel at $ISSUER"
# Zitadel writes the PAT during init; on a cold start it may take a moment.
deadline=$(( $(date +%s) + 180 ))
while [ ! -f "$PAT_FILE" ] && [ "$(date +%s)" -lt "$deadline" ]; do sleep 3; done
[ -f "$PAT_FILE" ] || fail "PAT not found ($PAT_FILE). Check 'docker logs musify-zitadel'."
PAT=$(tr -d '\r\n' <"$PAT_FILE")
api_call() {
# api_call <method> <path> [json body]
if [ $# -ge 3 ]; then
curl -fsS -X "$1" "$MGMT$2" \
-H "Authorization: Bearer $PAT" -H "Content-Type: application/json" -d "$3"
else
curl -fsS -X "$1" "$MGMT$2" -H "Authorization: Bearer $PAT"
fi
}
deadline=$(( $(date +%s) + 120 ))
until curl -fsS "$ISSUER/.well-known/openid-configuration" >/dev/null 2>&1; do
[ "$(date +%s)" -lt "$deadline" ] || fail "Zitadel did not respond at $ISSUER."
sleep 3
done
# 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.
curl -fsS -X PUT "$ISSUER/v2/features/instance" \
-H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"loginV2":{"required":false}}' >/dev/null 2>&1 \
|| echo " warning: could not disable the required Login UI v2"
name_query() {
printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$1"
}
# Project --------------------------------------------------------------------
PROJECT_ID=$(api_call POST "/projects/_search" "$(name_query "$PROJECT_NAME")" \
| jq -r --arg n "$PROJECT_NAME" '.result // [] | map(select(.name == $n)) | first | .id // empty')
if [ -n "$PROJECT_ID" ]; then
echo " project '$PROJECT_NAME' reused (id $PROJECT_ID)"
else
PROJECT_ID=$(api_call POST "/projects" "$(printf '{"name":"%s"}' "$PROJECT_NAME")" | jq -r '.id')
echo " project '$PROJECT_NAME' created (id $PROJECT_ID)"
fi
find_app() {
api_call POST "/projects/$PROJECT_ID/apps/_search" "$(name_query "$1")" \
| jq -r --arg n "$1" '.result // [] | map(select(.name == $n)) | first | .id // empty'
}
# API app: public client used by the Scalar UI to obtain a token -------------
API_APP_ID=$(find_app "$API_APP_NAME")
if [ -n "$API_APP_ID" ]; then
API_CLIENT_ID=$(api_call GET "/projects/$PROJECT_ID/apps/$API_APP_ID" | jq -r '.app.oidcConfig.clientId')
echo " app '$API_APP_NAME' reused (clientId $API_CLIENT_ID)"
else
API_CLIENT_ID=$(api_call POST "/projects/$PROJECT_ID/apps/oidc" "$(jq -n \
--arg name "$API_APP_NAME" --arg api "$API_URL" '{
name: $name,
redirectUris: [($api + "/scalar/"), ($api + "/scalar/oauth2-redirect.html")],
postLogoutRedirectUris: [($api + "/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: false,
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
accessTokenRoleAssertion: true,
idTokenRoleAssertion: true,
idTokenUserinfoAssertion: true
}')" | jq -r '.clientId')
echo " app '$API_APP_NAME' created (clientId $API_CLIENT_ID)"
fi
# Web app: confidential client used by the SvelteKit server ------------------
WEB_APP_ID=$(find_app "$WEB_APP_NAME")
WEB_CLIENT_SECRET=$(env_get "$ENV_FILE" WEB_CLIENT_SECRET)
if [ -n "$WEB_APP_ID" ]; then
WEB_CLIENT_ID=$(api_call GET "/projects/$PROJECT_ID/apps/$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId')
if [ -z "$WEB_CLIENT_SECRET" ]; then
# The secret is only returned once, so regenerate it if we no longer have it.
WEB_CLIENT_SECRET=$(api_call POST "/projects/$PROJECT_ID/apps/$WEB_APP_ID/oidc_config/_generate_client_secret" '{}' \
| jq -r '.clientSecret')
echo " app '$WEB_APP_NAME' reused (clientId $WEB_CLIENT_ID, secret regenerated)"
else
echo " app '$WEB_APP_NAME' reused (clientId $WEB_CLIENT_ID)"
fi
else
CREATED=$(api_call POST "/projects/$PROJECT_ID/apps/oidc" "$(jq -n \
--arg name "$WEB_APP_NAME" --arg web "$WEB_URL" '{
name: $name,
redirectUris: [($web + "/auth/callback")],
postLogoutRedirectUris: [($web + "/")],
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: false,
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
accessTokenRoleAssertion: true,
idTokenRoleAssertion: true,
idTokenUserinfoAssertion: true
}')")
WEB_CLIENT_ID=$(echo "$CREATED" | jq -r '.clientId')
WEB_CLIENT_SECRET=$(echo "$CREATED" | jq -r '.clientSecret')
echo " app '$WEB_APP_NAME' created (clientId $WEB_CLIENT_ID)"
fi
env_set "$ENV_FILE" AUTH_CLIENT_ID "$API_CLIENT_ID"
env_set "$ENV_FILE" WEB_CLIENT_ID "$WEB_CLIENT_ID"
env_set "$ENV_FILE" WEB_CLIENT_SECRET "$WEB_CLIENT_SECRET"
echo " wired into $(basename "$ENV_FILE")"