Onboarding architecture
This page explains the mechanics behind the three-step onboarding wizard: the single gate read that routes it, the write path behind each step, and how the gates leak into the rest of the portal. The guard stack, the sync-on-demand race bridge, and FB-2 error-screen behavior are covered in Authentication architecture; billing internals (checkout, webhooks, subscription activation) belong to the billing flow. Runtime probes: Org-creation flow and the Half-onboarded org fixture.
Source-checked against
optolink-backend@ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2 andoptolink-portal@ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (FLOW-002 harvest; B-017 re-verification pass). No live-browser re-audit of this flow has been recorded; the paid path through real Stripe/Paymob test mode is record-unverified (docs/FLOW-002.md§11).
The gate read drives everything
One endpoint decides what the wizard renders: GET /portal/onboarding/state (src/portal/portal-onboarding.controller.ts) returns {hasAppConfig, plan, bypass}:
hasAppConfig—prisma.appConfig.count({ where: { organizationId } }) > 0(step-2 gate)plan— rawOrganization.plan,nulluntil selected (step-3 gate)bypass— ops-org short-circuit ({hasAppConfig: true, plan: null, bypass: true}); the page navigates straight to/dashboard, which routes admins to/adminper FLOW-001
src/app/(auth)/onboarding/page.tsx renders whichever gate is tripped and advances by invalidating the ['onboarding-state'] query — there is no imperative step navigation. The query is enabled: hasOrg (src/hooks/use-onboarding.ts) because the endpoint needs an org-active session and 403s otherwise.
getState resolves the org itself, not via OrgResolutionPipe: no org claim → 403; a Clerk org with no DB row → clerkSync.ensureOrgFromClerk(clerkOrgId) (the webhook-race bridge, same mechanism as authentication); still missing → 403. This is what lets the wizard read state in the moment between createOrganization returning and the organization.created webhook landing.
Step 1: create-organization (Option E)
POST /portal/onboarding/create-organization is the only orgless portal route: no org claim, no role check, just ClerkAuthGuard + RateLimitGuard + @Auditable. Body {name} (trimmed, 1–100 chars). Everything below happens synchronously before the 201 (src/portal/portal-onboarding.service.ts createOrganization):
- 409 if the local
OrgMembertable says the caller already belongs to any org. Local DB is the source of truth; this is the org-farming/double-submit guard. clerkBackend.createOrganization({ name, createdBy: clerkUserId })— Clerk auto-adds the creator membership and firesorganization.created+organizationMembership.createdwebhooks, which later confirm the same state idempotently.- Local mirror in order:
Organization(plan: NULL) →User→OrgMember(role: OWNER), via the shared ensure-patterns (src/clerk/clerk-sync.service.ts). clerkBackend.updateMemberRole(role: 'owner')stamps the membership'spublicMetadata.role.
The FB-1 invariant: the owner role is stamped before any org-scoped token exists, so the caller's first org-scoped token already carries org_metadata.role = 'owner'. This eliminates the pre-fix failure mode by construction: before the fix, the first ~60 s of org-scoped tokens carried no role claim (measured: 403 at +38 s, first success at +96 s). Roles still come from Clerk session claims only; there is no RolesGuard DB fallback.
Name collisions: Organization.name is @unique. A P2002 on the local mirror triggers a best-effort clerkBackend.deleteOrganization rollback of the just-created Clerk org, then a 409; no orphaned Clerk orgs. This rollback covers only this endpoint's path: a duplicate name arriving via the webhook path still P2002s → 500 → Svix retry (left open, docs/FLOW-002.md).
Frontend completion order (name-workspace-step.tsx) is deliberate and load-bearing:
mutateAsync(create-organization)
→ clerk.setActive({ organization: clerkOrgId }) // mints the org-scoped token
→ clerk.user?.getOrganizationMemberships() // refreshes the cached membership list
→ invalidate ['onboarding-state'] // gate re-read advances to step 2
The memberships refresh exists because useAuth().role is derived from Clerk's cached membership list; skipping it leaves the fresh owner on the waiting-on-owner banner until a manual reload. The mutation hook itself has no onSuccess invalidation on purpose, so a failed setActive can be retried alone (this retry path is the "couldn't switch this session" screen; re-submitting the form would 409 on the already-in-org guard). Note: the fix-pass decision text names clerk.organization.switchTo, which does not exist in @clerk/react 6.12.2 — setActive({ organization }) is the actual (and equivalent) call.
Step 2: per-platform app-config upsert
PATCH /portal/app-config/:platform (src/portal/portal-app-config.controller.ts, @Roles('developer')); the platform segment is uppercased server-side. The DTO (src/app-config/dto/upsert-app-config.dto.ts) is partial by design (FLOW-013 D2a): every field optional, with bundleId + storeUrl required only when the call creates the row (they back non-nullable columns). The wizard ignores that latitude and always sends a full payload via buildAppConfigPayload (src/lib/app-config.ts). Fields: bundleId/storeUrl, teamId? (iOS), sha256Fingerprints?: string[] (Android), uriScheme?, fallbackWebUrl?. The list read is GET /portal/app-config → bare AppConfig[] (no wrapper), query key ['app-config'].
While configs load, step 2 renders a structure-matching skeleton (picker row + 4 labeled input rows + button row), not a spinner. commitIfDirty in app-config-step.tsx guarantees unsaved edits are flushed before advancing or switching platform cards.
Step 3: two plan paths
Free path — POST /portal/onboarding/select-plan (@Roles('owner'), @HttpCode(200)). The DTO accepts any known plan key (@IsIn over the plan-key list), but the service only ever writes 'starter':
- paid key →
UseBillingFlowException(422), body carriescode: 'USE_BILLING_FLOW'+billingEndpoint: '/portal/billing/checkout-session'(extra fields forwarded by theGlobalExceptionFilter) - ops org → 400
- re-selecting
starterwhenplanis already'starter'skips the write (idempotent)
Organization.plan is written directly — no Subscription row on this path.
Paid path — POST /portal/billing/checkout-session (src/portal/portal-billing.controller.ts L398–434, @Roles('owner'), @HttpCode(200) — note 200, the docs/TESTS-NOTES.md billing table's 201 is stale). Body {plan, currency}; STARTER → 400 ("STARTER is the default tier — no checkout needed"), ENTERPRISE → 400 (not self-serve). src/billing/subscriptions/checkout.service.ts creates an INACTIVE Subscription row up front so the activation webhook has a row to attach to, then returns {action: 'CHECKOUT', checkoutUrl, gateway, effectiveAt, scheduledPlan}. Gateway is picked server-side from currency: EGP → Paymob, USD → Stripe. A live subscription never checks out again (400 — change-plan/cancel/resume instead).
Frontend hand-off (select-package-step.tsx handlePaid): on a non-null checkoutUrl, stash the chosen plan in localStorage.subscriptionPlan (context for the success page while the webhook races the redirect), then window.location.href = checkoutUrl. Non-checkout actions surface a toast instead.
Completion is server-side. The plan gate clears only when Organization.plan becomes non-null, which for paid plans happens when the gateway webhook activates the subscription (entitlements granted only by gateway webhooks, idempotent on Invoice.gatewayInvoiceId — billing-v2 invariant, owned by FLOW-012). The /subscribe/:organizationId/success page (PORTAL-MAP.md PAGE-033) polls the active subscription; the wizard is never re-entered after a checkout redirect.
Guard wiring (portal)
/onboardingregisters outsideAppLayout/RequireOrgwith onlyRequireAuth(src/app/routes.tsxL61–67) — necessarily, since it is the pre-org route./getting-startedis a back-compat loader redirect to/dashboard(L94–97); fix-pass text saying the wizard "exits to/getting-started" is stale; the exit is/dashboard.useNeedsOnboarding()gates itsisLoading/isErroronhasOrg, so a stale error from a previous enabled run can't fire for orgless users (they'd see a guard banner instead of being routed to the wizard).RequireOrgredirects when!hasOrg || needsOnboarding; after sign-in,AuthRedirectroutes to/onboardingbefore honoring?redirect=or admin defaults. This is how half-onboarded users get caught from any app route.- State-read and guard-read failures render full-screen error banners with Retry (
StateErrorScreen/GuardErrorScreen), never redirects; two failed gates must not bounce into each other (FB-2, detailed in Authentication architecture). - Step-level roles: step 2 needs
developer+, step 3 needsowner(STEP_MIN_ROLEinpage.tsx, checked client-side viahasMinimumRoleon Clerk claims; backend decorators mirror it). Non-actors getWaitingOnOwner(step chrome + info banner; owner name resolved fromGET /portal/team/members, fired only whenhasOrg && role !== 'owner', falling back to "your organisation's owner" on read failure).
Downstream gates and audit
The wizard's gates are enforced again outside it:
- 428
APP_CONFIG_REQUIRED"Register your app before creating a link." — link creation on an org with zeroAppConfigrows (src/portal/app-config-required.exception.ts, checked inportal-link.controller.ts); 428 fires before the 402 quota check (see link creation pipeline). - 402
PLAN_NOT_SELECTED"No plan selected for this organization." — quota/data endpoints rejectplan = nullorgs (src/entitlements/plan-not-selected.exception.ts). - Rate limit: portal preset, 30 req/s per key (
rl:portalprefix) on every endpoint above.
Audit decorators: create-organization → @Auditable CREATE Organization; select-plan → @Auditable UPDATE Organization; app-config PATCH → @Auditable UPDATE AppConfig — all under AuditInterceptor.
Data model touched: Organization (plan, clerkOrgId, unique name, isOpsOrg), AppConfig (platform IOS|ANDROID + fields above), OrgMember (role enum), User (clerkUserId), Subscription (INACTIVE until webhook).
Testing gotchas
Full recipes: docs/TESTS-NOTES.md §Onboarding + Recipes G/H. The traps:
- FB-1 verification needs a fresh session — mint a new session after org creation before checking the role claim via raw Clerk API (Recipe G); a pre-existing token won't carry the role.
- Pre-provisioned/mirrored orgs stay trapped in the wizard (
plan = NULLuntil select-plan). Seed withUPDATE "Organization" SET plan='starter' WHERE "clerkOrgId"='$O'. Theplan='free'variant at TESTS-NOTES L1480 is stale —freeis not a plan key (ladder: starter/solo/growth/scale/enterprise). select-planneeds the full local mirror (User+OrgMember) to exist — it runs behindOrgResolutionPipe; onlystateself-resolves viaensureOrgFromClerk(org row only).- Benign first-call 401 on the state read at every session start (token bridge returns null pre-session; React Query retry self-heals in ~1 s). Not a bug.
- Fixture:
qa-ux357+clerk_test@optolink.io/Ux357Verify!2026: owner parked at wizard step 3 (org "UX Three Five Seven", one ANDROID configcom.ux357.app, plan NULL); Recipe H builds a viewer invitee on top (OTP424242for+clerk_testemails). - Error states without touching the backend: init-script a fetch wrapper returning 500 for the target paths (the portal uses
fetch, not axios) — pattern proven for the wizard state read. - Currency: wizard prices show EGP by default (
currencyForCountry("EG")→ EGP); don't mistake EGP amounts for a bug when testing from outside Egypt.
Known gaps
- Invitee role-stamp race (open, owned by FLOW-011): members accepting Clerk invitations wait up to ~60 s for the async
organizationMembership.createdrole stamp → 403s on their first role-gated actions. Related: the webhook stampsrole='owner'unconditionally — correct for the creator path, misleading for direct-API invitees. - No live re-audit: the four fix rounds were validated per-round (two independently, two implementer-verified); the wizard's end-to-end behavior with billing-v2 checkout (paid path through real Stripe/Paymob test mode) is record-unverified. A FLOW-012-adjacent live pass would close this.