Skip to Content

← All archived runs

Run: clerk-role-sync

run.md

Run: clerk-role-sync

  • branch: claude/clerk-role-sync-pipeline-q80p3d
  • pr: #652

01_define/output/spec.md

Spec: Clerk role sync (webhooks + JIT refresh)

  • slug: clerk-role-sync
  • personas: Admin (all personas benefit indirectly)
  • touches: apps/web/app/api/webhooks/clerk/route.ts, apps/web/proxy.ts, apps/web/lib/resolve-app-user-for-tenant.ts, packages/services/src/db/services/users/index.ts
  • complexity: standard

Problem

Mongo user.role is written once at first JIT provisioning (ensureProvisionedFromClerk) and then never re-synced. A role change made in the Clerk Dashboard never propagates to Mongo, so service logic keyed on the stored role — discriminator selection in userService.update, customer reactivation in resolveAppUserForTenant, operational lead/CSAT counts — runs on stale data (auth-permissions findings G3). There are no Clerk webhooks in the platform at all today, so Clerk is a write-once source rather than a live one. This advances Build the Bridge / 2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow: a reliable role mirror is the prerequisite for the rest of the platform-access-permissions epic (seeding permissions from roles), and it closes a correctness gap that silently mis-routes users whose role was changed after first login.

Proposed change

Make the Mongo role mirror track Clerk, via two independent paths so sync never depends on a single mechanism:

  1. A Clerk webhook routeapps/web/app/api/webhooks/clerk/route.ts, verified with Clerk's svix helper (@clerk/backend), added to the proxy public-route matcher so it is reachable without a session. It handles:
    • organizationMembership.created / organizationMembership.updated — resolve the Clerk org to a tenant via Tenant.clerkOrgId, map the membership role through mapClerkRoleToSystemRole, and update the mirrored user.role for that (clerkUserId, tenantId) row. An unmapped Clerk role is a no-op (fail closed), consistent with JIT provisioning.
    • organizationMembership.deleteddeactivate (isActive: false), never delete, the tenant user row for that membership.
    • user.updated — refresh the profile mirror fields (firstname, lastname, email, avatar) on the user's tenant row(s). Role is not sourced from this event.
  2. A JIT refresh in resolveAppUserForTenant — when an existing user is resolved and the live Clerk session's mapped role differs from the stored user.role, update the mirror during resolve. This covers the window before/without webhook delivery, so a user who signs in after a role change is corrected on their next request regardless of webhook state.

Role change and the discriminator (settled). Mongo users are Mongoose discriminators keyed on role. A role change updates the base role field via a raw/base-model update (Mongoose guards the discriminator key on the typed sub-model, so the sync writes through the base user model / collection, not the discriminator). The outgoing persona's discriminator fields are left in place (dormant) — this run does not $unset them. New persona-specific fields simply start absent (they are all optional in the schema) and are filled by that persona's own flows. Rationale: the write is simple and non-destructive, and no current read path treats a dormant field of the wrong persona as authoritative — reads select the discriminator by the (now correct) role.

The service layer gets the new sync operations (in packages/services/src/db/services/users/): sync role by Clerk membership, refresh profile by Clerk user id, and deactivate by Clerk membership — each tenant-scoped and idempotent, so repeated webhook deliveries converge.

Acceptance criteria

  • Changing a member's role in the Clerk Dashboard updates the Mongo user.role for that tenant row within webhook delivery time, without the user re-provisioning.
  • A request from an existing user whose live Clerk session role differs from the stored role updates the mirror during resolveAppUserForTenant (JIT refresh), independent of the webhook.
  • The webhook route rejects unsigned or invalid svix payloads (verification failure → non-2xx), and returns 2xx for a valid payload of an event type it does not act on.
  • Removing a user's org membership (organizationMembership.deleted) sets isActive: false on the tenant user row — the record is deactivated, not deleted.
  • A user.updated event refreshes the mirrored profile fields (firstname, lastname, email, avatar) on the user's tenant row(s) without changing role.
  • A role change across personas (e.g. expert → csm) updates user.role; subsequent reads resolve the new persona's discriminator, and the sync is idempotent under repeated delivery.
  • An organizationMembership event whose Clerk org has no matching Tenant.clerkOrgId, or whose Clerk role does not map to a known persona, is a safe no-op (returns 2xx, changes nothing).

Out of scope

  • Any permission resolution or seeding permissions from roles — this run is role-mirror integrity only (that is permission-foundation and the cutover features).
  • In-app role assignment UI — that is in-app-role-assignment.
  • $unset-ing or migrating the outgoing persona's discriminator fields on a role change (dormant fields are accepted this run, per the settled decision above).
  • Backfilling historically drifted roles for existing users — the webhook + JIT refresh correct rows going forward; a one-off reconciliation is a separate concern.
  • Any webhook events beyond organizationMembership.created/updated/deleted and user.updated.

Open questions

  • none — the discriminator-handling and user.updated scope questions were resolved during Define (dormant old fields; user.updated refreshes profile mirror fields only).

02_build/output/notes.md

Build notes: clerk-role-sync

  • commits: feat: clerk-role-sync — webhook route + JIT role/profile mirror sync

What changed

  • apps/web/app/api/webhooks/clerk/route.ts (new): svix-verified Clerk webhook via verifyWebhook (@clerk/nextjs/webhooks, falls back to CLERK_WEBHOOK_SIGNING_SECRET). Verification failure → 400; unhandled event types → 2xx ack. Handles organizationMembership.created/updated (re-mirror role), organizationMembership.deleted (deactivate), and user.updated (refresh profile). Org→tenant is resolved via tenantService.findByClerkOrgId; unknown org or unmapped role is a safe no-op.
  • apps/web/proxy.ts: added /api/webhooks/clerk(.*) to the public-route matcher so the session-less webhook isn't 302'd to /sign-in before it can verify its own signature.
  • apps/web/lib/resolve-app-user-for-tenant.ts: JIT role refresh in the existing-user branch — when the live Clerk session's mapped role differs from the stored mirror, syncRoleFromClerk corrects it during resolve, so sync doesn't depend on webhook delivery alone.
  • packages/services/src/db/services/users/index.ts: three idempotent, tenant-scoped methods —
    • syncRoleFromClerk(tenantId, clerkUserId, role) — writes the role discriminatorKey through the raw user collection (Mongoose refuses to change a discriminator key via an update); per the settled spec the outgoing persona's discriminator fields are left dormant, not unset.
    • deactivateByClerkUserId(tenantId, clerkUserId) — sets isActive: false (never deletes).
    • refreshProfileFromClerk(clerkUserId, profile) — updates base profile fields across the user's tenant rows (a user.updated event has no org scope); never touches role.

Acceptance criteria status

  • Changing a member's role in Clerk updates Mongo user.role within webhook delivery time — organizationMembership.updatedsyncMembershipRolesyncRoleFromClerk, no re-provision.
  • A request whose live session role differs from the stored role updates the mirror during resolveAppUserForTenant — JIT refresh block, independent of the webhook.
  • The webhook rejects unsigned/invalid svix payloads (verifyWebhook throws → 400) and returns 2xx for a valid payload of an unhandled event type (switch default acks).
  • organizationMembership.deleted sets isActive: false on the tenant row — deactivated, not deleted, via deactivateByClerkUserId.
  • user.updated refreshes mirrored profile fields (firstname, lastname, email, avatar) without changing rolerefreshProfileFromClerk.
  • A cross-persona role change updates user.role (raw discriminator-key write); reads then resolve the new persona's discriminator; the $ne guard makes repeated delivery idempotent.
  • An org with no matching Tenant.clerkOrgId, or an unmapped Clerk role, is a safe no-op (returns 2xx, changes nothing) — both handlers guard and return early.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here.
  • New env var to configure in the deploy environments before the webhook can verify: CLERK_WEBHOOK_SIGNING_SECRET (the svix signing secret from the Clerk Dashboard webhook endpoint). Without it, verifyWebhook fails closed (400) — safe, but the webhook is inert until set.

Notes for review

  • Role change goes through the raw driver deliberately — Mongoose guards the discriminator key on updates, so a normal findOneAndUpdate would silently not change role. Dormant old-persona fields are an accepted outcome per the Define decision (not a bug).
  • The Clerk webhook endpoint must be registered in the Clerk Dashboard for the four subscribed events; the route is otherwise unreachable traffic. Notifications are intentionally out of scope.

03_release/output/investor-update.md

Role changes in Clerk now propagate across the platform automatically

Who it's for: Admins — every persona benefits indirectly. What shipped: A signature-verified Clerk webhook plus a just-in-time refresh keep each user's stored role in sync, so a role change propagates without the user re-provisioning. Why it matters: Reliable role state is the foundation for platform permissions, advancing Build the Bridge — Objective 3: Validate Technical Infrastructure & Payout Flow.

Dig deeper: <merged-PR URL> · <changelog entry URL>

03_release/output/release.md

Release: clerk-role-sync

  • pr: #652 — https://github.com/sustentus/sustentus/pull/652 · merged: no (pending — Ready to merge ticked)
  • CI: green — Quality Project (format · lint · typecheck) success after the NextRequest typecheck fix; preview DB migrate, DB audit, spec structure, Vercel builds all green
  • technical docs: updated apps/docs/app/technical/architecture/system-diagram — the Clerk→services edge now reads "user + tenant + role sync (webhooks + JIT)" to reflect the new sync path
  • business docs: no business docs impact — role-mirror integrity is a correctness/reliability fix with no new user-facing capability or journey step
  • release notes: investor-only — no end-user note (internal reliability/security change; no in-app user action)
  • sent: <pending>

Review summary

  • /code-review run on the diff (standard → medium). Correctness, removed-behavior, cross-file, reuse, and conventions angles came back clean.
  • Finding (medium, fixed on branch): the JIT role refresh in resolve-app-user-for-tenant.ts awaited syncRoleFromClerk + re-fetch unguarded on the resolve hot path — a DB error would have failed the whole request. Wrapped best-effort so a sync failure serves the stale mirror and is corrected on the next request/webhook.
  • Note: the ?? "admin" → fail-closed change visible in the same file's diff is pre-existing auth-hygiene-fixes work on the branch base, not this feature.

Acceptance check (vs spec)

  • Clerk role change updates Mongo user.role — organizationMembership.updated → syncRoleFromClerk (raw discriminator-key write), no re-provision
  • JIT refresh corrects the mirror when the live session role differs — resolveAppUserForTenant existing-user branch
  • Webhook rejects unsigned/invalid svix payloads (verifyWebhook throws → 400); unhandled event types ack 2xx
  • organizationMembership.deleted deactivates (isActive:false), never deletes — deactivateByClerkUserId
  • user.updated refreshes profile fields (firstname/lastname/email/avatar), never role — refreshProfileFromClerk
  • Cross-persona role change updates role; reads resolve the new discriminator; idempotent via $ne guard
  • Unknown org / unmapped role is a safe 2xx no-op — both webhook handlers guard and return early