@tidecloak/create-nextjs 0.14.2 → 0.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,44 +2,73 @@
2
2
  set -euo pipefail
3
3
 
4
4
  # ─────────────────────────────────────────────────────────────────────────────
5
- # Resolve script directory (run from anywhere)
5
+ # @tidecloak/create-nextjs - tcinit.sh (SHIPPED template copy)
6
+ #
7
+ # Provisions a firstAdmin / threshold-1 Tide realm for a Next.js app, driving
8
+ # the CURRENT iga-core native governance model (drain PENDING change-requests
9
+ # via /iga/change-requests/{id}/approve, which records AND auto-commits at
10
+ # threshold-1). The legacy tide-admin/change-set/{type}/sign|commit path has
11
+ # been removed.
12
+ #
13
+ # This copy ships inside the scaffolded app so the user can re-run:
14
+ # cd <app> && bash init/tcinit.sh
15
+ # It resolves everything relative to SCRIPT_DIR and is runnable from anywhere.
16
+ #
17
+ # The governance body below (from "Helper: grab a fresh master admin-cli
18
+ # token" onward) is IDENTICAL to the canonical init/tcinit.sh. Both are kept
19
+ # in sync via `npm run sync:init`. Only this path/bootstrap preamble differs.
6
20
  # ─────────────────────────────────────────────────────────────────────────────
21
+
22
+ # ─── Resolve script directory (run from anywhere) ────────────────────────────
7
23
  SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
8
24
 
9
- # ─────────────────────────────────────────────────────────────────────────────
10
- # Load overrides from .env.example (CRLF-safe)
11
- # ─────────────────────────────────────────────────────────────────────────────
25
+ # ─── Load defaults from .env.example (CRLF-safe) ─────────────────────────────
26
+ # -f guard keeps it a no-op when absent.
12
27
  ENV_FILE="${SCRIPT_DIR}/.env.example"
13
28
  if [[ -f "$ENV_FILE" ]]; then
14
- if grep -q $'\r' "$ENV_FILE"; then
15
- TMP_ENV="$(mktemp)"
16
- tr -d '\r' < "$ENV_FILE" > "$TMP_ENV"
17
- # shellcheck disable=SC1090
18
- source "$TMP_ENV"
19
- rm -f "$TMP_ENV"
20
- else
21
- # shellcheck disable=SC1090
22
- source "$ENV_FILE"
23
- fi
29
+ # Apply each KEY=VALUE from the defaults file with FALLBACK semantics: a
30
+ # variable already present in the caller's environment is preserved and the
31
+ # default is ignored. We deliberately do NOT `source` the file, because raw
32
+ # assignments would hard-override caller-supplied env (e.g. NEW_REALM_NAME).
33
+ # CRLF-safe: a trailing CR is stripped from every line.
34
+ while IFS= read -r _env_line || [[ -n "$_env_line" ]]; do
35
+ _env_line="${_env_line%$'\r'}"
36
+ if [[ "$_env_line" =~ ^[[:space:]]*(#|$) ]]; then
37
+ continue
38
+ fi
39
+ if [[ "$_env_line" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$ ]]; then
40
+ _env_key="${BASH_REMATCH[2]}"
41
+ _env_val="${BASH_REMATCH[3]}"
42
+ _env_val="${_env_val#"${_env_val%%[![:space:]]*}"}"
43
+ _env_val="${_env_val%"${_env_val##*[![:space:]]}"}"
44
+ if [[ ${#_env_val} -ge 2 && "$_env_val" == \"*\" ]]; then
45
+ _env_val="${_env_val:1:${#_env_val}-2}"
46
+ elif [[ ${#_env_val} -ge 2 && "$_env_val" == \'*\' ]]; then
47
+ _env_val="${_env_val:1:${#_env_val}-2}"
48
+ fi
49
+ if [[ -z "${!_env_key:-}" ]]; then
50
+ printf -v "$_env_key" '%s' "$_env_val"
51
+ fi
52
+ fi
53
+ done < "$ENV_FILE"
54
+ unset _env_line _env_key _env_val
24
55
  fi
25
56
 
26
- # ─────────────────────────────────────────────────────────────────────────────
27
- # Defaults (override via env)
28
- # ─────────────────────────────────────────────────────────────────────────────
57
+ # ─── Defaults (override via env) ─────────────────────────────────────────────
29
58
  TIDECLOAK_LOCAL_URL="${TIDECLOAK_LOCAL_URL:-http://localhost:8080}"
30
59
  CLIENT_APP_URL="${CLIENT_APP_URL:-http://localhost:3000}"
31
- ADAPTER_OUTPUT_PATH="${ADAPTER_OUTPUT_PATH:-${SCRIPT_DIR}/tidecloak.json}"
32
60
  NEW_REALM_NAME="${NEW_REALM_NAME:-nextjs-test}"
33
61
  REALM_MGMT_CLIENT_ID="${REALM_MGMT_CLIENT_ID:-realm-management}"
34
62
  ADMIN_ROLE_NAME="${ADMIN_ROLE_NAME:-tide-realm-admin}"
35
63
  KC_USER="${KC_USER:-admin}"
36
64
  KC_PASSWORD="${KC_PASSWORD:-password}"
37
65
  CLIENT_NAME="${CLIENT_NAME:-myclient}"
66
+ SUBSCRIPTION_EMAIL="${SUBSCRIPTION_EMAIL:-test@demo.org}"
67
+ ADAPTER_OUTPUT_PATH="${ADAPTER_OUTPUT_PATH:-${SCRIPT_DIR}/tidecloak.json}"
68
+ MARKER_DIR="${SCRIPT_DIR}"
38
69
 
39
- # ─────────────────────────────────────────────────────────────────────────────
40
- # Find realm.json robustly
70
+ # ─── Find realm.json robustly ────────────────────────────────────────────────
41
71
  # Priority: env → same dir → parent → current working dir
42
- # ─────────────────────────────────────────────────────────────────────────────
43
72
  CANDIDATES=()
44
73
  [[ "${REALM_JSON_PATH:-}" != "" ]] && CANDIDATES+=("${REALM_JSON_PATH}")
45
74
  CANDIDATES+=("${SCRIPT_DIR}/realm.json" "${SCRIPT_DIR}/../realm.json" "$(pwd)/realm.json")
@@ -49,31 +78,64 @@ for p in "${CANDIDATES[@]}"; do
49
78
  if [[ -f "$p" ]]; then REALM_JSON_PATH="$p"; break; fi
50
79
  done
51
80
 
52
- echo "🔍 realm.json search candidates:"
53
- for p in "${CANDIDATES[@]}"; do echo " - $p"; done
54
-
55
81
  if [[ -z "${REALM_JSON_PATH}" ]]; then
56
- echo "❌ Could not find realm.json in the checked locations above." >&2
57
- echo " Put realm.json next to the script: ${SCRIPT_DIR}/realm.json" >&2
58
- echo " OR run with: REALM_JSON_PATH=/absolute/path/realm.json bash init/tcinit.sh" >&2
82
+ echo "ERROR: Could not find realm.json in:" >&2
83
+ for p in "${CANDIDATES[@]}"; do echo " - $p" >&2; done
84
+ echo " Put realm.json next to the script (${SCRIPT_DIR}/realm.json)" >&2
85
+ echo " OR run with: REALM_JSON_PATH=/abs/path/realm.json bash init/tcinit.sh" >&2
59
86
  exit 1
60
87
  fi
61
- echo "✅ Using realm.json: ${REALM_JSON_PATH}"
88
+ echo "Using realm.json: ${REALM_JSON_PATH}"
62
89
 
63
- # ─────────────────────────────────────────────────────────────────────────────
64
- # Dependency checks
65
- # ─────────────────────────────────────────────────────────────────────────────
66
- need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "❌ Missing dependency: $1" >&2; exit 1; }; }
90
+ # ─── Dependency checks ───────────────────────────────────────────────────────
91
+ need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Missing dependency: $1" >&2; exit 1; }; }
67
92
  need_cmd curl
68
93
  need_cmd jq
69
94
  need_cmd sed
70
95
  need_cmd mktemp
71
96
 
72
- # sed -i portability
97
+ # ─── sed -i portability ──────────────────────────────────────────────────────
73
98
  if sed --version >/dev/null 2>&1; then SED_INPLACE=(-i); else SED_INPLACE=(-i ''); fi
74
99
 
100
+ # ─── Cleanup handler ─────────────────────────────────────────────────────────
101
+ # INCOMPLETE_FIRSTADMIN is set to 1 once IGA is enabled (step 4) and cleared
102
+ # back to 0 after the final grant+flip (step 14) completes. If the script exits
103
+ # non-zero while this flag is set, the realm is half-provisioned (IGA on, admin
104
+ # not yet granted/flipped) and we warn loudly. We do NOT auto-delete the realm.
105
+ TMP_REALM_JSON=""
106
+ INCOMPLETE_FIRSTADMIN=0
107
+ cleanup() {
108
+ local rc=$?
109
+ [[ -n "${TMP_REALM_JSON}" && -f "${TMP_REALM_JSON}" ]] && rm -f "${TMP_REALM_JSON}" || true
110
+ [[ -f "${MARKER_DIR}/.realm_name" ]] && rm -f "${MARKER_DIR}/.realm_name" || true
111
+ if [[ "${rc}" != "0" && "${INCOMPLETE_FIRSTADMIN}" == "1" ]]; then
112
+ echo "" >&2
113
+ echo "──────────────────────────────────────────────────────────────────────" >&2
114
+ echo "WARNING: realm '${REALM_NAME:-?}' may be left in an incomplete firstAdmin" >&2
115
+ echo " state (IGA enabled, admin not yet granted/flipped)." >&2
116
+ echo " Complete provisioning or delete the realm before use." >&2
117
+ echo "──────────────────────────────────────────────────────────────────────" >&2
118
+ fi
119
+ }
120
+ trap cleanup EXIT
121
+
122
+ # ─── Plaintext-to-remote credential warning (non-fatal) ──────────────────────
123
+ # TIDECLOAK_LOCAL_URL is resolved in the preamble above. If we are about to send
124
+ # admin credentials and bearer tokens over cleartext http:// to a NON-loopback
125
+ # host, warn the user. Loopback http:// stays silent; https:// stays silent.
126
+ case "${TIDECLOAK_LOCAL_URL}" in
127
+ http://localhost|http://localhost:*|http://localhost/*) : ;;
128
+ http://127.0.0.1|http://127.0.0.1:*|http://127.0.0.1/*) : ;;
129
+ http://\[::1\]|http://\[::1\]:*|http://\[::1\]/*) : ;;
130
+ http://*)
131
+ echo "WARNING: TIDECLOAK_LOCAL_URL='${TIDECLOAK_LOCAL_URL}' uses cleartext http:// to a non-loopback host." >&2
132
+ echo " Admin credentials and bearer tokens will be sent UNENCRYPTED over the network." >&2
133
+ echo " Use an https:// URL when targeting a remote TideCloak." >&2
134
+ ;;
135
+ esac
136
+
75
137
  # ─────────────────────────────────────────────────────────────────────────────
76
- # Helper: grab an admin token
138
+ # Helper: grab a fresh master admin-cli token (master realm password grant)
77
139
  # ─────────────────────────────────────────────────────────────────────────────
78
140
  get_admin_token() {
79
141
  curl -s -X POST "${TIDECLOAK_LOCAL_URL}/realms/master/protocol/openid-connect/token" \
@@ -86,20 +148,82 @@ get_admin_token() {
86
148
  }
87
149
 
88
150
  # ─────────────────────────────────────────────────────────────────────────────
89
- # Cleanup handler
151
+ # Helper: status-capturing admin API call.
152
+ # Usage: api METHOD URL [extra curl args...]
153
+ # then read the results from the globals it sets:
154
+ # RESP_CODE - the HTTP status ("000" on hard network failure)
155
+ # RESP_BODY - the full response body
156
+ # IMPORTANT: call api WITHOUT command substitution. It sets globals in the
157
+ # CURRENT shell; running it as `code=$(api ...)` would execute it in a subshell
158
+ # and the RESP_BODY/RESP_CODE it sets would be discarded before the parent
159
+ # could read them.
160
+ # - Authorization: Bearer ${TOKEN} is added automatically (refresh TOKEN first).
161
+ # - Never aborts the script on a non-2xx HTTP response (curl -s exits 0);
162
+ # only a hard network failure yields RESP_CODE "000".
90
163
  # ─────────────────────────────────────────────────────────────────────────────
91
- TMP_REALM_JSON=""
92
- cleanup() {
93
- [[ -n "${TMP_REALM_JSON}" && -f "${TMP_REALM_JSON}" ]] && rm -f "${TMP_REALM_JSON}" || true
94
- [[ -f "${SCRIPT_DIR}/.realm_name" ]] && rm -f "${SCRIPT_DIR}/.realm_name" || true
164
+ RESP_CODE=""
165
+ RESP_BODY=""
166
+ api() {
167
+ local method="$1" url="$2"; shift 2
168
+ local tmp
169
+ tmp="$(mktemp)"
170
+ RESP_CODE=$(curl -s -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}" \
171
+ -H "Authorization: Bearer ${TOKEN}" "$@") || RESP_CODE="000"
172
+ RESP_BODY="$(cat "${tmp}")"
173
+ rm -f "${tmp}"
95
174
  }
96
- trap cleanup EXIT
97
175
 
98
176
  # ─────────────────────────────────────────────────────────────────────────────
99
- # Step 1: prepare realm JSON
177
+ # Drain PENDING IGA change-requests (current iga-core native governance).
178
+ # LIST returns a bare JSON array; per-item id = .id; /approve body = {}
179
+ # (application/json). At threshold-1 /approve records AND auto-commits, so
180
+ # there is no separate /commit. Loop-until-empty is required because approving
181
+ # one CR unblocks its dependents.
100
182
  # ─────────────────────────────────────────────────────────────────────────────
183
+ drain_change_requests() {
184
+ local label="${1:-}" rounds=0
185
+ echo "Draining PENDING change-requests ${label}..."
186
+ while (( rounds < 12 )); do
187
+ TOKEN="$(get_admin_token)"
188
+ local list_tmp list_code
189
+ list_tmp="$(mktemp)"
190
+ list_code=$(curl -s -o "${list_tmp}" -w "%{http_code}" \
191
+ "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/iga/change-requests?status=PENDING" \
192
+ -H "Authorization: Bearer ${TOKEN}" -H "Cache-Control: no-store") || list_code="000"
193
+ if [[ "${list_code}" == "401" || "${list_code}" == "403" ]]; then
194
+ rm -f "${list_tmp}"
195
+ echo "FATAL: change-request LIST returned HTTP ${list_code} ${label} - admin authentication/authorization failed." >&2
196
+ echo " Refusing to treat an auth failure as an empty inbox. Check KC_USER/KC_PASSWORD and admin privileges." >&2
197
+ exit 1
198
+ fi
199
+ ids=$(jq -r '.[].id // empty' < "${list_tmp}")
200
+ rm -f "${list_tmp}"
201
+ if [[ -z "${ids}" ]]; then echo " inbox empty ${label}"; return 0; fi
202
+ while IFS= read -r id; do
203
+ [[ -z "$id" ]] && continue
204
+ st=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
205
+ "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/iga/change-requests/${id}/approve" \
206
+ -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" -d '{}')
207
+ case "$st" in
208
+ 2*) : ;;
209
+ 401|403)
210
+ echo "FATAL: change-request approve ${id} returned HTTP ${st} ${label} - admin authentication/authorization failed." >&2
211
+ echo " Check KC_USER/KC_PASSWORD and admin privileges." >&2
212
+ exit 1 ;;
213
+ 404|409|412) : ;;
214
+ *) echo " WARN approve ${id} -> ${st}" ;;
215
+ esac
216
+ done <<< "${ids}"
217
+ ((rounds++)) || true
218
+ done
219
+ echo " WARN drain hit round cap ${label}"; return 0
220
+ }
221
+
222
+ # ═════════════════════════════════════════════════════════════════════════════
223
+ # Step 1: prepare realm JSON (placeholder substitution) + create the realm
224
+ # ═════════════════════════════════════════════════════════════════════════════
101
225
  REALM_NAME="${NEW_REALM_NAME}"
102
- echo "${REALM_NAME}" > "${SCRIPT_DIR}/.realm_name"
226
+ echo "${REALM_NAME}" > "${MARKER_DIR}/.realm_name"
103
227
 
104
228
  TMP_REALM_JSON="$(mktemp)"
105
229
  cp "${REALM_JSON_PATH}" "${TMP_REALM_JSON}"
@@ -108,177 +232,333 @@ sed "${SED_INPLACE[@]}" "s|http://localhost:3000|${CLIENT_APP_URL}|g" "${TMP_REA
108
232
  sed "${SED_INPLACE[@]}" "s|nextjs-test|${REALM_NAME}|g" "${TMP_REALM_JSON}"
109
233
  sed "${SED_INPLACE[@]}" "s|myclient|${CLIENT_NAME}|g" "${TMP_REALM_JSON}"
110
234
 
111
- # ─────────────────────────────────────────────────────────────────────────────
112
- # Step 2: create realm (allow 409 if already exists)
113
- # ─────────────────────────────────────────────────────────────────────────────
235
+ # TideCloak console origin for THIS realm (used by the signed IdP settings).
236
+ TIDE_CONSOLE_ORIGIN="${TIDECLOAK_LOCAL_URL}/realms/${REALM_NAME}/tide-console/"
237
+
238
+ echo "Creating realm '${REALM_NAME}'..."
114
239
  TOKEN="$(get_admin_token)"
115
- echo "🌍 Creating realm..."
116
- status=$(curl -s -o /dev/null -w "%{http_code}" \
117
- -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms" \
118
- -H "Authorization: Bearer ${TOKEN}" \
240
+ api POST "${TIDECLOAK_LOCAL_URL}/admin/realms" \
119
241
  -H "Content-Type: application/json" \
120
- --data-binary @"${TMP_REALM_JSON}")
121
-
122
- if [[ ${status} == 2* || ${status} -eq 409 ]]; then
123
- echo "✅ Realm created (or already exists)."
242
+ --data-binary @"${TMP_REALM_JSON}"
243
+ code="${RESP_CODE}"
244
+ if [[ "${code}" == 2* ]]; then
245
+ echo " realm create -> ${code} (created)"
246
+ elif [[ "${code}" == "409" ]]; then
247
+ if [[ "${ALLOW_EXISTING_REALM:-}" == "1" ]]; then
248
+ echo " WARNING: realm '${REALM_NAME}' already exists (HTTP 409); ALLOW_EXISTING_REALM=1 set, proceeding." >&2
249
+ echo " NOTE: the drain step approves ALL pending change-requests in this realm," >&2
250
+ echo " including any unrelated to this provisioning run." >&2
251
+ else
252
+ echo "ERROR: Realm '${REALM_NAME}' already exists; this script provisions FRESH realms and would" >&2
253
+ echo " approve all pending change-requests. Set ALLOW_EXISTING_REALM=1 to override." >&2
254
+ exit 1
255
+ fi
124
256
  else
125
- echo "❌ Realm creation failed (HTTP ${status})" >&2
257
+ echo "ERROR: realm creation failed (HTTP ${code})" >&2
258
+ echo " ${RESP_BODY}" >&2
126
259
  exit 1
127
260
  fi
128
261
 
129
- # ─────────────────────────────────────────────────────────────────────────────
130
- # Step 3: initialize Tide realm + IGA
131
- # ─────────────────────────────────────────────────────────────────────────────
262
+ # ═════════════════════════════════════════════════════════════════════════════
263
+ # Step 2: setUpTideRealm (mints the realm VRK on the ORK network)
264
+ # REQUIRES healthy ORKs. Non-2xx = fail loudly.
265
+ # ═════════════════════════════════════════════════════════════════════════════
266
+ echo "Setting up Tide realm (VRK keygen on the ORK network)..."
132
267
  TOKEN="$(get_admin_token)"
133
- echo "🔐 Initializing Tide realm + IGA..."
134
-
135
- curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/setUpTideRealm" \
136
- -H "Authorization: Bearer ${TOKEN}" \
268
+ api POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/setUpTideRealm" \
137
269
  -H "Content-Type: application/x-www-form-urlencoded" \
138
- --data-urlencode "email=${SUBSCRIPTION_EMAIL:-test@demo.org}" >/dev/null
139
-
140
- curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/toggle-iga" \
141
- -H "Authorization: Bearer ${TOKEN}" \
142
- -H "Content-Type: application/x-www-form-urlencoded" \
143
- --data-urlencode "isIGAEnabled=true" >/dev/null
144
-
145
- echo "✅ Tide realm + IGA done."
270
+ --data-urlencode "email=${SUBSCRIPTION_EMAIL}" \
271
+ --data-urlencode "isRagnarokEnabled=true" \
272
+ --data-urlencode "skipLicense=false"
273
+ code="${RESP_CODE}"
274
+ if [[ "${code}" != 2* ]]; then
275
+ echo "ERROR: setUpTideRealm failed (HTTP ${code})." >&2
276
+ echo " This step needs a healthy ORK network (VRK keygen)." >&2
277
+ echo " Check that your ORKs are reachable and the license email is valid." >&2
278
+ echo " Response: ${RESP_BODY}" >&2
279
+ exit 1
280
+ fi
281
+ echo " setUpTideRealm -> ${code}"
146
282
 
147
- # ─────────────────────────────────────────────────────────────────────────────
148
- # Approve & commit change-sets
149
- # ─────────────────────────────────────────────────────────────────────────────
150
- approve_and_commit() {
151
- local TYPE=$1
152
- echo "🔄 Processing ${TYPE} change-sets..."
153
- TOKEN="$(get_admin_token)"
154
- curl -s -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/change-set/${TYPE}/requests" \
155
- -H "Authorization: Bearer ${TOKEN}" \
156
- | jq -c '.[]' | while IFS= read -r req; do
157
- payload=$(jq -n \
158
- --arg id "$(jq -r .draftRecordId <<< "${req}")" \
159
- --arg cst "$(jq -r .changeSetType <<< "${req}")" \
160
- --arg at "$(jq -r .actionType <<< "${req}")" \
161
- '{changeSetId:$id,changeSetType:$cst,actionType:$at}')
162
-
163
- curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/change-set/sign" \
164
- -H "Authorization: Bearer ${TOKEN}" \
165
- -H "Content-Type: application/json" \
166
- -d "${payload}" >/dev/null
167
-
168
- curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/change-set/commit" \
169
- -H "Authorization: Bearer ${TOKEN}" \
170
- -H "Content-Type: application/json" \
171
- -d "${payload}" >/dev/null
172
- done
173
- echo "✅ ${TYPE^} change-sets done."
174
- }
175
- approve_and_commit clients
283
+ # ═════════════════════════════════════════════════════════════════════════════
284
+ # Step 3: stamp iga.attestor=tide on the realm BEFORE enabling IGA
285
+ # ═════════════════════════════════════════════════════════════════════════════
286
+ echo "Stamping iga.attestor=tide on the realm..."
287
+ TOKEN="$(get_admin_token)"
288
+ api GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}"
289
+ code="${RESP_CODE}"
290
+ if [[ "${code}" != 2* ]]; then
291
+ echo "ERROR: could not fetch realm representation (HTTP ${code})." >&2
292
+ echo " Response: ${RESP_BODY}" >&2
293
+ exit 1
294
+ fi
295
+ REALM_REP_FILE="$(mktemp)"
296
+ jq '.attributes = ((.attributes // {}) + {"iga.attestor":"tide"})' <<< "${RESP_BODY}" > "${REALM_REP_FILE}"
297
+ TOKEN="$(get_admin_token)"
298
+ api PUT "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}" \
299
+ -H "Content-Type: application/json" \
300
+ --data-binary @"${REALM_REP_FILE}"
301
+ code="${RESP_CODE}"
302
+ rm -f "${REALM_REP_FILE}"
303
+ if [[ "${code}" != 2* ]]; then
304
+ echo "ERROR: failed to set iga.attestor=tide (HTTP ${code})." >&2
305
+ echo " Response: ${RESP_BODY}" >&2
306
+ exit 1
307
+ fi
308
+ echo " iga.attestor=tide -> ${code}"
176
309
 
177
- # ─────────────────────────────────────────────────────────────────────────────
178
- # Step 4: create admin user + assign role
179
- # ─────────────────────────────────────────────────────────────────────────────
310
+ # ═════════════════════════════════════════════════════════════════════════════
311
+ # Step 4: enable IGA (application/json body {"enabled":true})
312
+ # ═════════════════════════════════════════════════════════════════════════════
313
+ echo "Enabling IGA governance..."
180
314
  TOKEN="$(get_admin_token)"
181
- echo "👤 Creating new admin user..."
182
- curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users" \
183
- -H "Authorization: Bearer ${TOKEN}" \
184
- -H "Content-Type: application/json" \
185
- -d '{"username":"admin","email":"admin@tidecloak.com","firstName":"admin","lastName":"user","enabled":true}' >/dev/null || true
186
-
187
- USER_ID=$(curl -s -X GET \
188
- "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users?username=admin" \
189
- -H "Authorization: Bearer ${TOKEN}" | jq -r '.[0].id')
190
-
191
- CLIENT_UUID=$(curl -s -X GET \
192
- "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients?clientId=${REALM_MGMT_CLIENT_ID}" \
193
- -H "Authorization: Bearer ${TOKEN}" | jq -r '.[0].id')
194
-
195
- ROLE_JSON=$(curl -s -X GET \
196
- "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients/${CLIENT_UUID}/roles/${ADMIN_ROLE_NAME}" \
197
- -H "Authorization: Bearer ${TOKEN}")
315
+ api POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/toggle-iga" \
316
+ -H "Content-Type: application/json" \
317
+ -d '{"enabled":true}'
318
+ code="${RESP_CODE}"
319
+ if [[ "${code}" == 2* ]]; then
320
+ echo " toggle-iga -> ${code} (IGA enabled)"
321
+ elif [[ "${code}" == "409" ]]; then
322
+ echo " toggle-iga -> 409 (already enabled)"
323
+ else
324
+ echo "ERROR: toggle-iga failed (HTTP ${code})." >&2
325
+ echo " Response: ${RESP_BODY}" >&2
326
+ exit 1
327
+ fi
198
328
 
199
- curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users/${USER_ID}/role-mappings/clients/${CLIENT_UUID}" \
200
- -H "Authorization: Bearer ${TOKEN}" \
201
- -H "Content-Type: application/json" \
202
- -d "[${ROLE_JSON}]" >/dev/null
329
+ # From here until the final grant+flip (step 14) the realm is half-provisioned:
330
+ # IGA is enabled but the first admin is not yet granted/flipped. A non-zero exit
331
+ # in this window triggers the incomplete-firstAdmin warning in cleanup().
332
+ INCOMPLETE_FIRSTADMIN=1
203
333
 
204
- echo "✅ Admin user & role done."
334
+ # ═════════════════════════════════════════════════════════════════════════════
335
+ # Step 5: drain the ADOPT change-requests raised by enabling IGA
336
+ # ═════════════════════════════════════════════════════════════════════════════
337
+ drain_change_requests "(after IGA enable)"
205
338
 
206
- # ─────────────────────────────────────────────────────────────────────────────
207
- # Step 5: generate invite link + wait
208
- # ─────────────────────────────────────────────────────────────────────────────
339
+ # ═════════════════════════════════════════════════════════════════════════════
340
+ # Step 6: create the admin user (tideInvitable + emailVerified:false at create)
341
+ # ═════════════════════════════════════════════════════════════════════════════
342
+ echo "Creating admin user..."
209
343
  TOKEN="$(get_admin_token)"
210
- echo "🔗 Generating invite link..."
211
- INVITE_LINK=$(curl -s -X POST \
212
- "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tideAdminResources/get-required-action-link?userId=${USER_ID}&lifespan=43200" \
213
- -H "Authorization: Bearer ${TOKEN}" \
344
+ api POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users" \
214
345
  -H "Content-Type: application/json" \
215
- -d '["link-tide-account-action"]')
346
+ -d '{"username":"admin","email":"admin@tidecloak.com","firstName":"Admin","lastName":"User","enabled":true,"emailVerified":false,"attributes":{"tideInvitable":["true"]}}'
347
+ code="${RESP_CODE}"
348
+ if [[ "${code}" == 2* || "${code}" == "201" ]]; then
349
+ echo " create user -> ${code}"
350
+ elif [[ "${code}" == "409" ]]; then
351
+ echo " create user -> 409 (already exists)"
352
+ elif [[ "${code}" == "202" ]]; then
353
+ echo " create user -> 202 (IGA change-request parked)"
354
+ else
355
+ echo "ERROR: admin user creation failed (HTTP ${code})." >&2
356
+ echo " Response: ${RESP_BODY}" >&2
357
+ exit 1
358
+ fi
216
359
 
217
- echo "🔗 Invite link: ${INVITE_LINK}"
218
- echo "→ Send this link to the user so they can link their account."
360
+ # ═════════════════════════════════════════════════════════════════════════════
361
+ # Step 7: drain (commits the CREATE_USER change-request) BEFORE resolving id
362
+ # ═════════════════════════════════════════════════════════════════════════════
363
+ drain_change_requests "(after user create)"
219
364
 
220
- MAX_TRIES=3
221
- attempt=1
222
- while true; do
223
- echo -n "Checking link status (attempt ${attempt}/${MAX_TRIES})… "
224
- ATTRS=$(curl -s -X GET \
225
- "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users?username=admin" \
226
- -H "Authorization: Bearer ${TOKEN}")
365
+ # ═════════════════════════════════════════════════════════════════════════════
366
+ # Step 8: resolve the admin userId (non-empty proves the create committed)
367
+ # ═════════════════════════════════════════════════════════════════════════════
368
+ echo "Resolving admin userId..."
369
+ TOKEN="$(get_admin_token)"
370
+ api GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users?username=admin&exact=true"
371
+ code="${RESP_CODE}"
372
+ USER_ID="$(jq -r '.[0].id // empty' <<< "${RESP_BODY}")"
373
+ if [[ -z "${USER_ID}" ]]; then
374
+ echo "ERROR: could not resolve admin userId (HTTP ${code}); the CREATE_USER change-request may not have committed." >&2
375
+ echo " Response: ${RESP_BODY}" >&2
376
+ exit 1
377
+ fi
378
+ echo " admin userId = ${USER_ID}"
227
379
 
380
+ # ═════════════════════════════════════════════════════════════════════════════
381
+ # Step 9: mint the Tide enrollment link (link-tide-account-action)
382
+ # ═════════════════════════════════════════════════════════════════════════════
383
+ echo "Minting Tide enrollment link..."
384
+ TOKEN="$(get_admin_token)"
385
+ api POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tideAdminResources/get-required-action-link?userId=${USER_ID}&lifespan=3600" \
386
+ -H "Content-Type: application/json" \
387
+ -H "Accept: text/plain" \
388
+ -d '["link-tide-account-action"]'
389
+ code="${RESP_CODE}"
390
+ if [[ "${code}" != 2* ]]; then
391
+ echo "ERROR: failed to mint enrollment link (HTTP ${code})." >&2
392
+ echo " Response: ${RESP_BODY}" >&2
393
+ exit 1
394
+ fi
395
+ INVITE_LINK="${RESP_BODY}"
396
+
397
+ # ═════════════════════════════════════════════════════════════════════════════
398
+ # Step 10: HUMAN GATE - complete Tide enrollment in the enclave, then poll
399
+ # until BOTH tideUserKey and vuid attributes appear on the user.
400
+ # Re-fetch the admin token every iteration (master token ~60s).
401
+ # ═════════════════════════════════════════════════════════════════════════════
402
+ echo ""
403
+ echo "──────────────────────────────────────────────────────────────────────"
404
+ echo " ACTION REQUIRED: Open this URL and complete Tide enrollment:"
405
+ echo ""
406
+ echo " ${INVITE_LINK}"
407
+ echo ""
408
+ echo " Enrollment happens in the Tide enclave and can take a few minutes."
409
+ echo " This script will wait and continue automatically once you are enrolled."
410
+ echo "──────────────────────────────────────────────────────────────────────"
411
+ echo ""
412
+
413
+ poll_attempt=0
414
+ POLL_MAX=100000 # effectively unbounded (~4s * 100000)
415
+ while true; do
416
+ poll_attempt=$(( poll_attempt + 1 ))
417
+ TOKEN="$(get_admin_token)"
418
+ ATTRS=$(curl -s "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users?username=admin&exact=true&briefRepresentation=false" \
419
+ -H "Authorization: Bearer ${TOKEN}" -H "Cache-Control: no-store")
228
420
  KEY=$(jq -r '.[0].attributes.tideUserKey[0] // empty' <<< "${ATTRS}")
229
421
  VUID=$(jq -r '.[0].attributes.vuid[0] // empty' <<< "${ATTRS}")
230
-
231
422
  if [[ -n "${KEY}" && -n "${VUID}" ]]; then
232
- echo "✅ Linked!"
423
+ echo " Tide enrollment detected (tideUserKey + vuid present)."
233
424
  break
234
425
  fi
235
-
236
- if (( attempt >= MAX_TRIES )); then
237
- echo "⚠️ Max retries reached (${MAX_TRIES}). Moving on."
238
- break
426
+ if (( poll_attempt >= POLL_MAX )); then
427
+ echo "ERROR: gave up waiting for Tide enrollment after ${poll_attempt} polls." >&2
428
+ exit 1
239
429
  fi
240
-
241
- read -t 30 -p "Not linked yet; press ENTER to retry or wait 30s…" || true
242
- echo
243
- ((attempt++))
430
+ printf " waiting for enrollment... (poll %s)\r" "${poll_attempt}"
431
+ sleep 4
244
432
  done
245
433
 
246
- approve_and_commit users
434
+ # ═════════════════════════════════════════════════════════════════════════════
435
+ # Step 11: drain (commits the tideUserKey / vuid change-requests)
436
+ # ═════════════════════════════════════════════════════════════════════════════
437
+ drain_change_requests "(after enrollment)"
247
438
 
248
- # ─────────────────────────────────────────────────────────────────────────────
249
- # Step 6: update CustomAdminUIDomain
250
- # ─────────────────────────────────────────────────────────────────────────────
439
+ # ═════════════════════════════════════════════════════════════════════════════
440
+ # Step 12: point the tide IdP at THIS realm's console origin, then sign it
441
+ # ═════════════════════════════════════════════════════════════════════════════
442
+ echo "Signing tide IdP settings (CustomAdminUIDomain -> console origin)..."
251
443
  TOKEN="$(get_admin_token)"
252
- echo "🌐 Updating CustomAdminUIDomain..."
253
-
254
- INST_JSON=$(curl -s -X GET \
255
- "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/identity-provider/instances/tide" \
256
- -H "Authorization: Bearer ${TOKEN}")
444
+ api GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/identity-provider/instances/tide"
445
+ code="${RESP_CODE}"
446
+ if [[ "${code}" != 2* ]]; then
447
+ echo "ERROR: could not fetch tide IdP instance (HTTP ${code})." >&2
448
+ echo " Response: ${RESP_BODY}" >&2
449
+ exit 1
450
+ fi
451
+ IDP_REP_FILE="$(mktemp)"
452
+ jq --arg d "${TIDE_CONSOLE_ORIGIN}" '.config.CustomAdminUIDomain = $d' <<< "${RESP_BODY}" > "${IDP_REP_FILE}"
453
+ TOKEN="$(get_admin_token)"
454
+ api PUT "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/identity-provider/instances/tide" \
455
+ -H "Content-Type: application/json" \
456
+ --data-binary @"${IDP_REP_FILE}"
457
+ code="${RESP_CODE}"
458
+ rm -f "${IDP_REP_FILE}"
459
+ if [[ "${code}" != 2* ]]; then
460
+ echo "ERROR: failed to update tide IdP settings (HTTP ${code})." >&2
461
+ echo " Response: ${RESP_BODY}" >&2
462
+ exit 1
463
+ fi
464
+ TOKEN="$(get_admin_token)"
465
+ api POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/sign-idp-settings" \
466
+ -H "Content-Type: text/plain" \
467
+ --data-binary ""
468
+ code="${RESP_CODE}"
469
+ if [[ "${code}" != 2* ]]; then
470
+ echo "ERROR: sign-idp-settings failed (HTTP ${code}). Needs healthy ORKs." >&2
471
+ echo " Response: ${RESP_BODY}" >&2
472
+ exit 1
473
+ fi
474
+ echo " IdP settings signed -> ${code}"
257
475
 
258
- UPDATED_JSON=$(jq --arg d "${CLIENT_APP_URL}" '.config.CustomAdminUIDomain = $d' <<< "${INST_JSON}")
476
+ # ═════════════════════════════════════════════════════════════════════════════
477
+ # Step 13: grant tide-realm-admin to the enrolled admin (AFTER enrollment)
478
+ # ═════════════════════════════════════════════════════════════════════════════
479
+ echo "Granting tide-realm-admin to the admin user..."
480
+ TOKEN="$(get_admin_token)"
481
+ api GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients?clientId=${REALM_MGMT_CLIENT_ID}"
482
+ code="${RESP_CODE}"
483
+ RM_UUID="$(jq -r '.[0].id // empty' <<< "${RESP_BODY}")"
484
+ if [[ -z "${RM_UUID}" ]]; then
485
+ echo "ERROR: could not resolve realm-management client uuid (HTTP ${code})." >&2
486
+ exit 1
487
+ fi
488
+ TOKEN="$(get_admin_token)"
489
+ api GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients/${RM_UUID}/roles/${ADMIN_ROLE_NAME}"
490
+ code="${RESP_CODE}"
491
+ if [[ "${code}" != 2* ]]; then
492
+ echo "ERROR: could not fetch ${ADMIN_ROLE_NAME} role (HTTP ${code})." >&2
493
+ echo " Response: ${RESP_BODY}" >&2
494
+ exit 1
495
+ fi
496
+ ROLE_REP="${RESP_BODY}"
259
497
 
260
- curl -s -X PUT "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/identity-provider/instances/tide" \
261
- -H "Authorization: Bearer ${TOKEN}" \
262
- -H "Content-Type: application/json" \
263
- -d "${UPDATED_JSON}" >/dev/null
498
+ # idempotency: skip if the mapping already exists
499
+ TOKEN="$(get_admin_token)"
500
+ api GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users/${USER_ID}/role-mappings/clients/${RM_UUID}"
501
+ code="${RESP_CODE}"
502
+ ALREADY=$(jq -r --arg r "${ADMIN_ROLE_NAME}" '[.[]?.name] | index($r) // empty' <<< "${RESP_BODY}")
503
+ if [[ -n "${ALREADY}" ]]; then
504
+ echo " ${ADMIN_ROLE_NAME} already mapped; skipping grant."
505
+ else
506
+ TOKEN="$(get_admin_token)"
507
+ api POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users/${USER_ID}/role-mappings/clients/${RM_UUID}" \
508
+ -H "Content-Type: application/json" \
509
+ -d "[${ROLE_REP}]"
510
+ code="${RESP_CODE}"
511
+ case "${code}" in
512
+ 2*) echo " grant -> ${code}" ;;
513
+ 202) echo " grant -> 202 (change-request parked)" ;;
514
+ 409)
515
+ echo " grant -> 409; draining users then retrying once..."
516
+ drain_change_requests "(grant 409 retry)"
517
+ TOKEN="$(get_admin_token)"
518
+ api POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users/${USER_ID}/role-mappings/clients/${RM_UUID}" \
519
+ -H "Content-Type: application/json" \
520
+ -d "[${ROLE_REP}]"
521
+ code="${RESP_CODE}"
522
+ case "${code}" in
523
+ 2*|202|409) echo " grant retry -> ${code}" ;;
524
+ *) echo "ERROR: grant retry failed (HTTP ${code})." >&2; echo " ${RESP_BODY}" >&2; exit 1 ;;
525
+ esac
526
+ ;;
527
+ *) echo "ERROR: grant failed (HTTP ${code})." >&2; echo " ${RESP_BODY}" >&2; exit 1 ;;
528
+ esac
529
+ fi
264
530
 
265
- curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/sign-idp-settings" \
266
- -H "Authorization: Bearer ${TOKEN}" >/dev/null
531
+ # ═════════════════════════════════════════════════════════════════════════════
532
+ # Step 14: FINAL drain - commits GRANT_ROLES and flips firstAdmin->multiAdmin.
533
+ # No drain or governed write may run after this point.
534
+ # ═════════════════════════════════════════════════════════════════════════════
535
+ drain_change_requests "(final: grant + firstAdmin->multiAdmin flip)"
267
536
 
268
- echo "✅ CustomAdminUIDomain updated + signed."
537
+ # Grant+flip committed: the realm is fully provisioned. Clear the incomplete
538
+ # state flag so a later non-zero exit does NOT emit the half-provisioned warning.
539
+ INCOMPLETE_FIRSTADMIN=0
269
540
 
270
- # ─────────────────────────────────────────────────────────────────────────────
271
- # Step 7: fetch adapter config
272
- # ─────────────────────────────────────────────────────────────────────────────
541
+ # ═════════════════════════════════════════════════════════════════════════════
542
+ # Step 15: fetch the client adapter config (tidecloak.json)
543
+ # ═════════════════════════════════════════════════════════════════════════════
544
+ echo "Fetching adapter config..."
273
545
  TOKEN="$(get_admin_token)"
274
- echo "📥 Fetching adapter config…"
275
- CLIENT_UUID=$(curl -s -X GET \
276
- "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients?clientId=${CLIENT_NAME}" \
277
- -H "Authorization: Bearer ${TOKEN}" | jq -r '.[0].id')
278
-
279
- curl -s -X GET \
546
+ api GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients?clientId=${CLIENT_NAME}"
547
+ code="${RESP_CODE}"
548
+ CLIENT_UUID="$(jq -r '.[0].id // empty' <<< "${RESP_BODY}")"
549
+ if [[ -z "${CLIENT_UUID}" ]]; then
550
+ echo "ERROR: could not resolve client '${CLIENT_NAME}' uuid (HTTP ${code})." >&2
551
+ exit 1
552
+ fi
553
+ TOKEN="$(get_admin_token)"
554
+ adapter_code=$(curl -s -o "${ADAPTER_OUTPUT_PATH}" -w "%{http_code}" \
280
555
  "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/get-installations-provider?clientId=${CLIENT_UUID}&providerId=keycloak-oidc-keycloak-json" \
281
- -H "Authorization: Bearer ${TOKEN}" > "${ADAPTER_OUTPUT_PATH}"
556
+ -H "Authorization: Bearer ${TOKEN}")
557
+ if [[ "${adapter_code}" != 2* ]]; then
558
+ echo "ERROR: failed to fetch adapter config (HTTP ${adapter_code})." >&2
559
+ exit 1
560
+ fi
561
+ echo " Adapter config saved to ${ADAPTER_OUTPUT_PATH}"
282
562
 
283
- echo "✅ Adapter config saved to ${ADAPTER_OUTPUT_PATH}"
284
- echo "🎉 All done!"
563
+ echo ""
564
+ echo "All done. Realm '${REALM_NAME}' is provisioned and the first admin is enrolled."