Org-creation flow probe
Scope: testing POST /portal/onboarding/create-organization end-to-end
without the portal UI. Uses curl + CLERK_SECRET_KEY directly against the
Clerk Backend API (no clerk CLI login needed — the CLI session is often
expired). The point of steps 4–5 is the FB-1 invariant: the owner role is
stamped synchronously, so the caller's first org-scoped token already
carries it (the old flow 403'd role-gated writes for ~60s).
Source-checked against optolink-backend @ 29f8589, 2026-10-06 (
portal-onboarding.service.createOrganization: 409-if-membered → Clerk create withcreatedBy→ mirror → stamppublicMetadata.role='owner', all before the response; response{clerkOrgId, name};PATCH /portal/app-config/:platformis@Roles('developer')). The Clerk Backend API form-encoding behaviors in step 1 are recorded 2026-09-03; re-verify live on next use.
cd optolink-backend && set -a && . ./.env && set +a && TS=$(date +%s)
# 1. fresh orgless user (⚠ form-encoded: JSON with ARRAY values is rejected;
# plain-string JSON also works, arrays don't)
U=$(curl -s -X POST https://api.clerk.com/v1/users \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
--data-urlencode "email_address=qa-x-$TS+clerk_test@optolink.io" \
--data-urlencode "password=Xq7-$(openssl rand -hex 4)!Aa" \
| jq -r .id)
# 2. session + ORGLESS token (no organization_id)
SID=$(curl -s -X POST https://api.clerk.com/v1/sessions \
-H "Authorization: Bearer $CLERK_SECRET_KEY" -H 'Content-Type: application/json' \
-d "{\"user_id\":\"$U\"}" | jq -r .id)
T=$(curl -s -X POST "https://api.clerk.com/v1/sessions/$SID/tokens" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" -H 'Content-Type: application/json' \
-d '{}' | jq -r .jwt)
# 3. create → 201 {clerkOrgId, name}
O=$(curl -s -X POST localhost:3000/portal/onboarding/create-organization \
-H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"name":"RT Org $TS"}' | jq -r .clerkOrgId)
# 4. THE FB-1 CHECK: mint the org token from a session created AFTER the org
# exists — an older session's token carries no/stale org claims
SID2=$(curl -s -X POST https://api.clerk.com/v1/sessions \
-H "Authorization: Bearer $CLERK_SECRET_KEY" -H 'Content-Type: application/json' \
-d "{\"user_id\":\"$U\"}" | jq -r .id)
OT=$(curl -s -X POST "https://api.clerk.com/v1/sessions/$SID2/tokens" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" -H 'Content-Type: application/json' \
-d "{\"organization_id\":\"$O\"}" | jq -r .jwt)
echo "$OT" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.org_metadata'
# expect {"role":"owner"} — role stamped BEFORE the first org-scoped token
# 5. immediate role-gated write (the old flow 403'd here for ~60s)
curl -s -w '\n%{http_code}\n' -X PATCH localhost:3000/portal/app-config/IOS \
-H "Authorization: Bearer $OT" -H 'Content-Type: application/json' \
-d '{"bundleId":"com.rt.test","storeUrl":"https://apps.apple.com/app/id1"}'
# expect 200
# 6. cleanup — see cleanup.md. Note: single-value list endpoints return a BARE
# array (no {data} wrapper) — `jq '.[0]'`, not `jq '.data[0]'`
The FB-1 semantics themselves (why the stamp is synchronous, what the webhooks confirm) are on auth-clerk, "Self-registration path".