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