initialize() { attempt=0 until initialized=$(vault sys/init | jq -er '.initialized|tostring'); do attempt=$((attempt+1)); [ "$attempt" -lt 90 ] || fail 'Vault did not start'; sleep 1 done if [ "$initialized" = false ]; then [ ! -e "$STATE/admin/init.json" ] || fail 'Vault data missing but bootstrap material exists; restore matching volume or reset both' printf '{"secret_shares":1,"secret_threshold":1}' | vault sys/init POST | write "$STATE/admin/init.json" fi existing jq -r '.keys_base64[0]' "$STATE/admin/init.json" | write "$STATE/vault/unseal" 444 attempt=0 until vault sys/seal-status | jq -e '.sealed==false' >/dev/null; do attempt=$((attempt+1)); [ "$attempt" -lt 60 ] || fail 'Vault did not unseal'; sleep 1 done } mounts() { vault sys/mounts > "$WORK/mounts" for name in kv database; do if ! jq -e --arg key "$name/" 'has($key)' < "$WORK/mounts" >/dev/null; then if ! jq -e --arg key "$name/" '.data|has($key)' < "$WORK/mounts" >/dev/null; then jq -n --arg name "$name" '{type:$name} + (if $name=="kv" then {options:{version:"2"}} else {} end)' | vault "sys/mounts/$name" POST >/dev/null fi fi done if ! vault sys/auth | jq -e '.data|has("approle/")' >/dev/null; then printf '{"type":"approle"}' | vault sys/auth/approle POST >/dev/null fi } provision_mongo() { if [ "$(kv infrastructure/mongo/bootstrap)" = null ]; then random > "$WORK/password"; random 384 > "$WORK/keyfile"; random > "$WORK/manager" jq -n --rawfile password "$WORK/password" --rawfile keyfile "$WORK/keyfile" --rawfile manager "$WORK/manager" '{password:($password|rtrimstr("\n")),keyfile:($keyfile|rtrimstr("\n")),manager_password:($manager|rtrimstr("\n"))}' | put infrastructure/mongo/bootstrap fi kv infrastructure/mongo/bootstrap > "$WORK/mongo-secret" jq -r '.password' "$WORK/mongo-secret" | write "$STATE/mongo/password" jq -r '.keyfile' "$WORK/mongo-secret" | write "$STATE/mongo/keyfile" compose up -d mongo admin_auth > "$WORK/admin.js" attempt=0 while :; do if { cat "$WORK/admin.js"; printf 'try {rs.status()} catch(e) {if(e.code===94) rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}); else throw e;} print(db.hello().isWritablePrimary);'; } | mongo > "$WORK/primary" && grep -q true "$WORK/primary"; then break; fi attempt=$((attempt+1)); [ "$attempt" -lt 90 ] || fail 'Mongo replica set did not become primary'; sleep 2 done if [ "$(vault database/config/mongo GET yes)" = null ]; then { cat "$WORK/admin.js"; jq -r '"if(!admin.getUser(\"vault-manager\")) admin.createUser({user:\"vault-manager\",pwd:"+(.manager_password|tojson)+",roles:[{role:\"userAdminAnyDatabase\",db:\"admin\"}]});"' "$WORK/mongo-secret"; } | mongo >/dev/null || fail 'Mongo manager provisioning failed' jq '{plugin_name:"mongodb-database-plugin",allowed_roles:["bff","notifications"],connection_url:"mongodb://{{username}}:{{password}}@mongo:27017/admin?replicaSet=rs0",username:"vault-manager",password:.manager_password}' "$WORK/mongo-secret" | vault database/config/mongo POST >/dev/null printf '{}' | vault database/rotate-root/mongo POST >/dev/null fi for role in bff notifications; do database=sxchnge; [ "$role" != notifications ] || database=sxchnge_notifications jq -n --arg database "$database" '{db_name:"mongo",creation_statements:({db:"admin",roles:[{role:"readWrite",db:$database}]}|tojson),default_ttl:"1h",max_ttl:"24h"}' | vault "database/roles/$role" POST >/dev/null done { cat "$WORK/admin.js"; cat infra/local/mongo/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed' } provision_agents() { for service in bff notifications nats; do directory="$STATE/agents/$service" mkdir -p "$directory"; chmod 755 "$directory" case "$service" in bff) paths='services/bff/jwt';; notifications) paths='infrastructure/nats/auth services/notifications/email/resend';; nats) paths=infrastructure/nats/auth;; esac : > "$WORK/policy" for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done if [ "$service" != nats ]; then printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy"; fi jq -n --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$service" POST >/dev/null jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444 if [ ! -f "$directory/secret-id" ]; then printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444 fi if ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444 if compose ps --status running --services "$service" | grep -qx "$service"; then compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service" fi fi done } up() { [ -f "$STATE/images.env" ] || fail 'Run make build before make up' exec 9> "$STATE/bootstrap.lock" flock -n 9 || fail 'Another local bootstrap is running' mkdir -p "$STATE/vault"; chmod 755 "$STATE/vault" compose up -d vault # On first bootstrap Compose creates the network after this tool container starts. if ! docker inspect "$HOSTNAME" --format '{{json .NetworkSettings.Networks}}' | jq -e 'has("sxchnge-local_default")' >/dev/null; then docker network connect sxchnge-local_default "$HOSTNAME" fi initialize mounts migrate_secrets for path in infrastructure/nats/auth services/bff/jwt; do if [ "$(kv "$path")" = null ]; then field=token; [ "$path" != services/bff/jwt ] || field=jwt random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path" fi done printf '{"api_key":""}' | ensure services/notifications/email/resend provision_mongo provision_agents compose up -d --wait --wait-timeout 120 nats retire_secrets normalize_email_secret kv services/notifications/email/resend | email_valid || fail 'Vault kv/services/notifications/email/resend needs api_key. Run make email; configure the verified sender in infra/local/notifications/runtime.yml, then make build && make up.' compose up -d --wait --wait-timeout 180 export_ca cat <<'MESSAGE' ✅ SXCHNGE development environment started! Services: Frontend: https://localhost:5126 API: https://localhost:5126/api/v1 API health: https://localhost:5126/api/v1/health Vault: http://127.0.0.1:18200 HTTPS: Trust .local/dev/ca.pem in your browser or system trust store. See docs/local-development.md for setup instructions. View status: make ps View logs: make logs Stop: make down MESSAGE } email() { existing printf 'Resend API key (hidden): ' >&2 tty_state=$(stty -g) trap 'stty "$tty_state"; cleanup' EXIT stty -echo IFS= read -r key stty "$tty_state" printf '\n' >&2 [ -n "$key" ] || fail 'A key is required' printf '%s' "$key" > "$WORK/email-key" normalize_email_secret jq -n --rawfile key "$WORK/email-key" '{api_key:$key}' | put services/notifications/email/resend echo 'API key saved in Vault. Set the verified sender in infra/local/notifications/runtime.yml, then make build && make up.' }