Files
cameleer-saas/docker/logto-bootstrap.sh
hsiegeln e90ca29920
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 36s
fix: centralize public hostname into single PUBLIC_HOST env var
All public-facing URLs (Logto OIDC, redirect URIs, dashboard links) now
derive from PUBLIC_HOST in .env instead of scattered localhost references.
Resolves Docker networking ambiguity where localhost inside containers
doesn't reach the host machine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:07:20 +02:00

508 lines
19 KiB
Bash

#!/bin/sh
set -e
# Cameleer SaaS — Bootstrap Script
# Creates Logto apps, users, organizations, roles.
# Seeds cameleer_saas DB with tenant, environment, license.
# Configures cameleer3-server OIDC.
# Idempotent: checks existence before creating.
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://logto:3002}"
LOGTO_PUBLIC_ENDPOINT="${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001}"
MGMT_API_RESOURCE="https://default.logto.app/api"
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
PG_HOST="${PG_HOST:-postgres}"
PG_USER="${PG_USER:-cameleer}"
PG_DB_LOGTO="logto"
PG_DB_SAAS="${PG_DB_SAAS:-cameleer_saas}"
# App names
SPA_APP_NAME="Cameleer SaaS"
M2M_APP_NAME="Cameleer SaaS Backend"
TRAD_APP_NAME="Cameleer Dashboard"
API_RESOURCE_INDICATOR="https://api.cameleer.local"
API_RESOURCE_NAME="Cameleer SaaS API"
# Users (configurable via env vars)
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
TENANT_ADMIN_USER="${TENANT_ADMIN_USER:-camel}"
TENANT_ADMIN_PASS="${TENANT_ADMIN_PASS:-camel}"
# Tenant config
TENANT_NAME="Example Tenant"
TENANT_SLUG="default"
BOOTSTRAP_TOKEN="${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}"
# Server config
SERVER_ENDPOINT="${SERVER_ENDPOINT:-http://cameleer3-server:8081}"
SERVER_UI_USER="${SERVER_UI_USER:-admin}"
SERVER_UI_PASS="${SERVER_UI_PASS:-admin}"
# Redirect URIs (derived from PUBLIC_HOST)
HOST="${PUBLIC_HOST:-localhost}"
SPA_REDIRECT_URIS="[\"http://${HOST}/callback\",\"http://${HOST}:8080/callback\",\"http://${HOST}:5173/callback\"]"
SPA_POST_LOGOUT_URIS="[\"http://${HOST}/login\",\"http://${HOST}:8080/login\",\"http://${HOST}:5173/login\"]"
TRAD_REDIRECT_URIS="[\"http://${HOST}:8081/oidc/callback\"]"
TRAD_POST_LOGOUT_URIS="[\"http://${HOST}:8081\"]"
log() { echo "[bootstrap] $1"; }
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
# Install jq + curl
apk add --no-cache jq curl >/dev/null 2>&1
# Read cached secrets from previous run
if [ -f "$BOOTSTRAP_FILE" ]; then
CACHED_M2M_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
CACHED_TRAD_SECRET=$(jq -r '.tradAppSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
log "Found cached bootstrap file"
fi
# ============================================================
# PHASE 1: Wait for services
# ============================================================
log "Waiting for Logto..."
for i in $(seq 1 60); do
if curl -sf "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" >/dev/null 2>&1; then
log "Logto is ready."
break
fi
[ "$i" -eq 60 ] && { log "ERROR: Logto not ready after 60s"; exit 1; }
sleep 1
done
log "Waiting for cameleer3-server..."
for i in $(seq 1 60); do
if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then
log "cameleer3-server is ready."
break
fi
[ "$i" -eq 60 ] && { log "WARNING: cameleer3-server not ready after 60s — skipping OIDC config"; }
sleep 1
done
# ============================================================
# PHASE 2: Get Management API token
# ============================================================
log "Reading m-default secret from database..."
pgpass
M_DEFAULT_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
"SELECT secret FROM applications WHERE id = 'm-default' AND tenant_id = 'admin';")
[ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found"; exit 1; }
get_admin_token() {
curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Host: ${HOST}:3002" \
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
}
get_default_token() {
curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Host: ${HOST}:3001" \
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
}
log "Getting Management API token..."
TOKEN_RESPONSE=$(get_admin_token "m-default" "$M_DEFAULT_SECRET")
TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
log "Got Management API token."
# --- Helper: Logto API calls ---
api_get() {
curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}:3001" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
}
api_post() {
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3001" \
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
}
api_put() {
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3001" \
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
}
api_delete() {
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}:3001" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
}
# ============================================================
# PHASE 3: Create Logto applications
# ============================================================
EXISTING_APPS=$(api_get "/api/applications")
# --- SPA app (for SaaS frontend) ---
SPA_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$SPA_APP_NAME\" and .type == \"SPA\") | .id")
if [ -n "$SPA_ID" ]; then
log "SPA app exists: $SPA_ID"
else
log "Creating SPA app..."
SPA_RESPONSE=$(api_post "/api/applications" "{
\"name\": \"$SPA_APP_NAME\",
\"type\": \"SPA\",
\"oidcClientMetadata\": {
\"redirectUris\": $SPA_REDIRECT_URIS,
\"postLogoutRedirectUris\": $SPA_POST_LOGOUT_URIS
}
}")
SPA_ID=$(echo "$SPA_RESPONSE" | jq -r '.id')
log "Created SPA app: $SPA_ID"
fi
# --- Traditional Web App (for cameleer3-server OIDC) ---
TRAD_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\" and .type == \"Traditional\") | .id")
TRAD_SECRET=""
if [ -n "$TRAD_ID" ]; then
log "Traditional app exists: $TRAD_ID"
TRAD_SECRET="${CACHED_TRAD_SECRET:-}"
else
log "Creating Traditional Web app..."
TRAD_RESPONSE=$(api_post "/api/applications" "{
\"name\": \"$TRAD_APP_NAME\",
\"type\": \"Traditional\",
\"oidcClientMetadata\": {
\"redirectUris\": $TRAD_REDIRECT_URIS,
\"postLogoutRedirectUris\": $TRAD_POST_LOGOUT_URIS
}
}")
TRAD_ID=$(echo "$TRAD_RESPONSE" | jq -r '.id')
TRAD_SECRET=$(echo "$TRAD_RESPONSE" | jq -r '.secret')
[ "$TRAD_SECRET" = "null" ] && TRAD_SECRET=""
log "Created Traditional app: $TRAD_ID"
fi
# --- API resource ---
EXISTING_RESOURCES=$(api_get "/api/resources")
API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id")
if [ -n "$API_RESOURCE_ID" ]; then
log "API resource exists: $API_RESOURCE_ID"
else
log "Creating API resource..."
RESOURCE_RESPONSE=$(api_post "/api/resources" "{
\"name\": \"$API_RESOURCE_NAME\",
\"indicator\": \"$API_RESOURCE_INDICATOR\"
}")
API_RESOURCE_ID=$(echo "$RESOURCE_RESPONSE" | jq -r '.id')
log "Created API resource: $API_RESOURCE_ID"
fi
# ============================================================
# PHASE 3b: Create API resource scopes
# ============================================================
log "Creating API resource scopes..."
EXISTING_SCOPES=$(api_get "/api/resources/${API_RESOURCE_ID}/scopes")
create_scope() {
local name="$1"
local desc="$2"
local existing_id=$(echo "$EXISTING_SCOPES" | jq -r ".[] | select(.name == \"$name\") | .id")
if [ -n "$existing_id" ]; then
log " Scope '$name' exists: $existing_id" >&2
echo "$existing_id"
else
local resp=$(api_post "/api/resources/${API_RESOURCE_ID}/scopes" "{\"name\": \"$name\", \"description\": \"$desc\"}")
local new_id=$(echo "$resp" | jq -r '.id')
log " Created scope '$name': $new_id" >&2
echo "$new_id"
fi
}
# Platform-level scope
SCOPE_PLATFORM_ADMIN=$(create_scope "platform:admin" "SaaS platform administration")
# Tenant-level scopes
SCOPE_TENANT_MANAGE=$(create_scope "tenant:manage" "Manage tenant settings")
SCOPE_BILLING_MANAGE=$(create_scope "billing:manage" "Manage billing")
SCOPE_TEAM_MANAGE=$(create_scope "team:manage" "Manage team members")
SCOPE_APPS_MANAGE=$(create_scope "apps:manage" "Create and delete apps")
SCOPE_APPS_DEPLOY=$(create_scope "apps:deploy" "Deploy apps")
SCOPE_SECRETS_MANAGE=$(create_scope "secrets:manage" "Manage secrets")
SCOPE_OBSERVE_READ=$(create_scope "observe:read" "View observability data")
SCOPE_OBSERVE_DEBUG=$(create_scope "observe:debug" "Debug and replay operations")
SCOPE_SETTINGS_MANAGE=$(create_scope "settings:manage" "Manage settings")
# Collect scope IDs for role assignment
ALL_TENANT_SCOPE_IDS="\"$SCOPE_TENANT_MANAGE\",\"$SCOPE_BILLING_MANAGE\",\"$SCOPE_TEAM_MANAGE\",\"$SCOPE_APPS_MANAGE\",\"$SCOPE_APPS_DEPLOY\",\"$SCOPE_SECRETS_MANAGE\",\"$SCOPE_OBSERVE_READ\",\"$SCOPE_OBSERVE_DEBUG\",\"$SCOPE_SETTINGS_MANAGE\""
ALL_SCOPE_IDS="\"$SCOPE_PLATFORM_ADMIN\",$ALL_TENANT_SCOPE_IDS"
MEMBER_SCOPE_IDS="\"$SCOPE_APPS_DEPLOY\",\"$SCOPE_OBSERVE_READ\",\"$SCOPE_OBSERVE_DEBUG\""
# --- M2M app ---
M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id")
M2M_SECRET=""
if [ -n "$M2M_ID" ]; then
log "M2M app exists: $M2M_ID"
M2M_SECRET="${CACHED_M2M_SECRET:-}"
else
log "Creating M2M app..."
M2M_RESPONSE=$(api_post "/api/applications" "{
\"name\": \"$M2M_APP_NAME\",
\"type\": \"MachineToMachine\"
}")
M2M_ID=$(echo "$M2M_RESPONSE" | jq -r '.id')
M2M_SECRET=$(echo "$M2M_RESPONSE" | jq -r '.secret')
log "Created M2M app: $M2M_ID"
# Assign Management API role
log "Assigning Management API access to M2M app..."
pgpass
MGMT_RESOURCE_ID=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
"SELECT id FROM resources WHERE indicator = '$MGMT_API_RESOURCE' AND tenant_id = 'default';")
if [ -n "$MGMT_RESOURCE_ID" ]; then
SCOPE_IDS=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
"SELECT json_agg(id) FROM scopes WHERE resource_id = '$MGMT_RESOURCE_ID' AND tenant_id = 'default';" | tr -d '[:space:]')
ROLE_RESPONSE=$(api_post "/api/roles" "{
\"name\": \"cameleer-m2m-management\",
\"description\": \"Full Management API access for Cameleer SaaS\",
\"type\": \"MachineToMachine\",
\"scopeIds\": $SCOPE_IDS
}")
ROLE_ID=$(echo "$ROLE_RESPONSE" | jq -r '.id')
if [ -n "$ROLE_ID" ] && [ "$ROLE_ID" != "null" ]; then
api_post "/api/roles/$ROLE_ID/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null
log "Assigned Management API role to M2M app."
VERIFY=$(get_default_token "$M2M_ID" "$M2M_SECRET")
VERIFY_TOKEN=$(echo "$VERIFY" | jq -r '.access_token')
if [ -n "$VERIFY_TOKEN" ] && [ "$VERIFY_TOKEN" != "null" ]; then
log "Verified M2M app works."
else
log "WARNING: M2M verification failed"
M2M_SECRET=""
fi
fi
fi
fi
# ============================================================
# PHASE 4: Create roles
# ============================================================
# --- Global platform-admin role ---
log "Creating platform-admin role..."
EXISTING_ROLES=$(api_get "/api/roles")
PA_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "platform-admin" and .type == "User") | .id')
if [ -n "$PA_ROLE_ID" ]; then
log "platform-admin role exists: $PA_ROLE_ID"
# Ensure scopes are assigned (idempotent)
api_post "/api/roles/${PA_ROLE_ID}/scopes" "{\"scopeIds\": [$ALL_SCOPE_IDS]}" >/dev/null 2>&1
else
PA_RESPONSE=$(api_post "/api/roles" "{
\"name\": \"platform-admin\",
\"description\": \"SaaS platform administrator\",
\"type\": \"User\",
\"scopeIds\": [$ALL_SCOPE_IDS]
}")
PA_ROLE_ID=$(echo "$PA_RESPONSE" | jq -r '.id')
log "Created platform-admin role: $PA_ROLE_ID"
fi
# --- Organization roles ---
log "Creating organization roles..."
EXISTING_ORG_ROLES=$(api_get "/api/organization-roles")
ORG_ADMIN_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "admin") | .id')
if [ -n "$ORG_ADMIN_ROLE_ID" ]; then
log "Org admin role exists: $ORG_ADMIN_ROLE_ID"
else
ORG_ADMIN_RESPONSE=$(api_post "/api/organization-roles" "{
\"name\": \"admin\",
\"description\": \"Tenant administrator\"
}")
ORG_ADMIN_ROLE_ID=$(echo "$ORG_ADMIN_RESPONSE" | jq -r '.id')
log "Created org admin role: $ORG_ADMIN_ROLE_ID"
fi
ORG_MEMBER_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "member") | .id')
if [ -z "$ORG_MEMBER_ROLE_ID" ]; then
ORG_MEMBER_RESPONSE=$(api_post "/api/organization-roles" "{
\"name\": \"member\",
\"description\": \"Tenant member\"
}")
ORG_MEMBER_ROLE_ID=$(echo "$ORG_MEMBER_RESPONSE" | jq -r '.id')
log "Created org member role: $ORG_MEMBER_ROLE_ID"
fi
# Assign API resource scopes to org roles (these appear in org-scoped resource tokens)
log "Assigning API resource scopes to organization roles..."
api_put "/api/organization-roles/${ORG_ADMIN_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$ALL_TENANT_SCOPE_IDS]}" >/dev/null 2>&1
api_put "/api/organization-roles/${ORG_MEMBER_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$MEMBER_SCOPE_IDS]}" >/dev/null 2>&1
log "API resource scopes assigned to organization roles."
# ============================================================
# PHASE 5: Create users
# ============================================================
# --- SaaS Owner ---
log "Checking for SaaS owner user '$SAAS_ADMIN_USER'..."
ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id")
if [ -n "$ADMIN_USER_ID" ]; then
log "SaaS owner exists: $ADMIN_USER_ID"
else
log "Creating SaaS owner '$SAAS_ADMIN_USER'..."
ADMIN_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$SAAS_ADMIN_USER\",
\"password\": \"$SAAS_ADMIN_PASS\",
\"name\": \"Platform Admin\"
}")
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
log "Created SaaS owner: $ADMIN_USER_ID"
# Assign platform-admin role
if [ -n "$PA_ROLE_ID" ] && [ "$PA_ROLE_ID" != "null" ]; then
api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$PA_ROLE_ID\"]}" >/dev/null
log "Assigned platform-admin role to SaaS owner."
fi
fi
# --- Tenant Admin ---
log "Checking for tenant admin '$TENANT_ADMIN_USER'..."
TENANT_USER_ID=$(api_get "/api/users?search=$TENANT_ADMIN_USER" | jq -r ".[] | select(.username == \"$TENANT_ADMIN_USER\") | .id")
if [ -n "$TENANT_USER_ID" ]; then
log "Tenant admin exists: $TENANT_USER_ID"
else
log "Creating tenant admin '$TENANT_ADMIN_USER'..."
TENANT_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$TENANT_ADMIN_USER\",
\"password\": \"$TENANT_ADMIN_PASS\",
\"name\": \"Tenant Admin\"
}")
TENANT_USER_ID=$(echo "$TENANT_RESPONSE" | jq -r '.id')
log "Created tenant admin: $TENANT_USER_ID"
fi
# ============================================================
# PHASE 6: Create organization + add users
# ============================================================
log "Checking for organization '$TENANT_NAME'..."
EXISTING_ORGS=$(api_get "/api/organizations")
ORG_ID=$(echo "$EXISTING_ORGS" | jq -r ".[] | select(.name == \"$TENANT_NAME\") | .id")
if [ -n "$ORG_ID" ]; then
log "Organization exists: $ORG_ID"
else
log "Creating organization '$TENANT_NAME'..."
ORG_RESPONSE=$(api_post "/api/organizations" "{
\"name\": \"$TENANT_NAME\",
\"description\": \"Bootstrap demo tenant\"
}")
ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id')
log "Created organization: $ORG_ID"
fi
# Add users to organization
if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then
log "Adding tenant user to organization..."
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$TENANT_USER_ID\"]}" >/dev/null 2>&1
api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_MEMBER_ROLE_ID\"]}" >/dev/null 2>&1
log "Tenant user added to org with member role."
fi
if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then
log "Adding SaaS owner to organization..."
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1
api_put "/api/organizations/$ORG_ID/users/$ADMIN_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_ADMIN_ROLE_ID\"]}" >/dev/null 2>&1
log "SaaS owner added to org with admin role."
fi
# ============================================================
# PHASE 7: Configure cameleer3-server OIDC
# ============================================================
SERVER_HEALTHY="no"
for i in 1 2 3; do
if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then
SERVER_HEALTHY="yes"
break
fi
sleep 2
done
log "Phase 7 check: SERVER_HEALTHY=$SERVER_HEALTHY, TRAD_SECRET length=${#TRAD_SECRET}"
if [ "$SERVER_HEALTHY" = "yes" ] && [ -n "$TRAD_SECRET" ]; then
log "Configuring cameleer3-server OIDC..."
# Login to server as admin
SERVER_TOKEN_RESPONSE=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\": \"$SERVER_UI_USER\", \"password\": \"$SERVER_UI_PASS\"}")
SERVER_TOKEN=$(echo "$SERVER_TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null)
if [ -n "$SERVER_TOKEN" ] && [ "$SERVER_TOKEN" != "null" ]; then
# Configure OIDC
OIDC_RESPONSE=$(curl -s -X PUT "${SERVER_ENDPOINT}/api/v1/admin/oidc" \
-H "Authorization: Bearer $SERVER_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"enabled\": true,
\"issuerUri\": \"$LOGTO_PUBLIC_ENDPOINT/oidc\",
\"clientId\": \"$TRAD_ID\",
\"clientSecret\": \"$TRAD_SECRET\",
\"autoSignup\": true,
\"defaultRoles\": [\"VIEWER\"],
\"displayNameClaim\": \"name\"
}")
log "OIDC config response: $(echo "$OIDC_RESPONSE" | head -c 200)"
log "cameleer3-server OIDC configured."
else
log "WARNING: Could not login to cameleer3-server — skipping OIDC config"
fi
else
log "WARNING: cameleer3-server not available or no Traditional app secret — skipping OIDC config"
fi
# ============================================================
# PHASE 9: Cleanup seeded apps
# ============================================================
if [ -n "$M2M_SECRET" ]; then
log "Cleaning up seeded apps with known secrets..."
for SEEDED_ID in "m-default" "m-admin" "s6cz3wajdv8gtdyz8e941"; do
if echo "$EXISTING_APPS" | jq -e ".[] | select(.id == \"$SEEDED_ID\")" >/dev/null 2>&1; then
api_delete "/api/applications/$SEEDED_ID"
log "Deleted seeded app: $SEEDED_ID"
fi
done
fi
# ============================================================
# PHASE 10: Write bootstrap results
# ============================================================
log "Writing bootstrap config to $BOOTSTRAP_FILE..."
mkdir -p "$(dirname "$BOOTSTRAP_FILE")"
cat > "$BOOTSTRAP_FILE" <<EOF
{
"spaClientId": "$SPA_ID",
"m2mClientId": "$M2M_ID",
"m2mClientSecret": "$M2M_SECRET",
"tradAppId": "$TRAD_ID",
"tradAppSecret": "$TRAD_SECRET",
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
"organizationId": "$ORG_ID",
"tenantName": "$TENANT_NAME",
"tenantSlug": "$TENANT_SLUG",
"bootstrapToken": "$BOOTSTRAP_TOKEN",
"platformAdminUser": "$SAAS_ADMIN_USER",
"tenantAdminUser": "$TENANT_ADMIN_USER",
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
"oidcAudience": "$API_RESOURCE_INDICATOR"
}
EOF
log ""
log "=== Bootstrap complete! ==="
log " SaaS Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS"
log " Tenant Admin: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS"
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
log " Organization: $ORG_ID"
log " SPA Client ID: $SPA_ID"