import { OrgId, RoleName, UserId } from "@maple/domain/http" import { Effect, Layer, Option, Schema } from "effect" import { HttpRouter, type HttpServerRequest } from "effect/http" import { Env } from "@maple/backend/platform/Env" import { MembershipRevocationService } from "@maple/backend/services/auth/MembershipRevocationService" import { CLERK_MEMBERSHIP_EVENTS, decodeClerkEnvelope, decodeClerkOrganizationMembership, decodeClerkUserCreated, decodeClerkUserDeleted, isClerkMembershipEvent, membershipProductEvent, signupCompletedEvent, type ClerkMembershipEventType, } from "@maple/backend/services/product-events/clerk-events" import { ProductEventsService } from "@maple/backend/services/product-events/ProductEventsService" import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" import { receiveSvixWebhook, webhookText } from "./svix-receiver" /** * Clerk webhook receiver. Three jobs: * * - `user.created` → `signup_completed` product event; membership created / * deleted → `member_joined` / `member_removed`. * - membership lifecycle → an org audit entry (`member.added` / `role_changed` * / `removed`). Membership is the one org change the web app makes in Clerk * rather than through Maple's API, so this receiver is the only writer of * `affected_user`. Enabling the three `organizationMembership.*` events in * the Clerk dashboard is what turns it on. * - membership removal/demotion → the revocation sweep. This is the only thing * in Maple that runs when somebody stops being a member of an organization; * without it the membership cache and every user-bound credential keep * answering yes indefinitely. * * Public route; authenticity is the Svix signature (`CLERK_WEBHOOK_SECRET`). * Any other event type is acknowledged with 200 so Clerk does not retry it. */ const ROUTE = "/webhooks/clerk" const decodeOrgId = Schema.decodeUnknownOption(OrgId) const decodeUserId = Schema.decodeUnknownOption(UserId) const decodeRole = Schema.decodeUnknownOption(RoleName) const handler = Effect.gen(function* () { const env = yield* Env const productEvents = yield* ProductEventsService const revocation = yield* MembershipRevocationService const audit = yield* AuditLogService /** * A revocation that only half-ran is the state this whole handler exists to * prevent, so a failure is loud: 500, Clerk retries, and the failed delivery * is visible in both Clerk's dashboard and the span. Every step is * predicate-driven and idempotent, so a retry converges rather than * double-applying — which is what makes "fail loudly" the safe choice here * rather than a source of duplicate work. */ const revoke = Effect.fn("ClerkWebhook.revoke")(function* (run: Effect.Effect) { const outcome = yield* run.pipe(Effect.option) if (Option.isNone(outcome)) { yield* Effect.annotateCurrentSpan({ "http.response.status_code": 500, "maple.webhook.outcome": "revocation_failed", }) return webhookText("Membership revocation failed", 500) } yield* Effect.annotateCurrentSpan({ "http.response.status_code": 200, "maple.webhook.outcome": "handled", }) return webhookText("ok", 200) }) const handleMembership = Effect.fn("ClerkWebhook.membership")(function* ( data: unknown, event: ClerkMembershipEventType, timestamp: number | undefined, ) { const payload = yield* decodeClerkOrganizationMembership(data).pipe(Effect.option) if (Option.isNone(payload)) { yield* Effect.annotateCurrentSpan({ "http.response.status_code": 400, "maple.webhook.outcome": "parse_rejected", }) return webhookText("Unrecognized membership payload", 400) } const orgId = decodeOrgId(payload.value.organization.id) const userId = decodeUserId(payload.value.public_user_data.user_id) if (Option.isNone(orgId) || Option.isNone(userId)) { yield* Effect.annotateCurrentSpan({ "http.response.status_code": 400, "maple.webhook.outcome": "parse_rejected", }) return webhookText("Unrecognized membership identifiers", 400) } // Clerk's payload names the member, never the admin who acted, so // attributing this to a user would be a guess. `system` says truthfully // that Maple learned of the change rather than made it. Recorded before // the sweep: a revocation that fails and retries must not lose the entry. yield* audit.record({ orgId: orgId.value, actor: { type: "system" }, source: "system", action: `member.${CLERK_MEMBERSHIP_EVENTS[event]}`, affectedUserId: userId.value, ...(payload.value.role !== undefined ? { metadata: { role: payload.value.role } } : undefined), }) const productEvent = membershipProductEvent(event, payload.value, timestamp) if (event === "organizationMembership.created") { if (productEvent !== undefined) yield* productEvents.track(productEvent) yield* Effect.annotateCurrentSpan({ "http.response.status_code": 200, "maple.webhook.outcome": "handled", }) return webhookText("ok", 200) } if (event === "organizationMembership.deleted") { const response = yield* revoke(revocation.revokeMembership(orgId.value, userId.value)) // Only once the sweep succeeded: a 500 is retried by Clerk and would emit it twice. if (response.status === 200 && productEvent !== undefined) yield* productEvents.track(productEvent) return response } // A role change is not just a cache eviction: CLI/MCP/device keys pin the // minting user's roles and are never re-checked, so a demotion has to go // and take those keys away. A promotion strips nothing. const role = payload.value.role === undefined ? Option.none() : decodeRole(payload.value.role) if (Option.isNone(role)) { // Clerk always sends `role`; without one we cannot tell a demotion from // a promotion, and guessing "demoted" would revoke a promoted admin's // keys. Evict and say so loudly instead of silently doing nothing. yield* Effect.logError("Clerk membership update carried no usable role").pipe( Effect.annotateLogs({ orgId: orgId.value, userId: userId.value }), ) yield* Effect.annotateCurrentSpan({ "maple.webhook.role_missing": true }) return yield* revoke(revocation.invalidateMembership(userId.value)) } return yield* revoke(revocation.demoteMembership(orgId.value, userId.value, [role.value])) }) return Effect.fn("ClerkWebhook.receive")(function* (req: HttpServerRequest.HttpServerRequest) { yield* Effect.annotateCurrentSpan({ "http.request.method": req.method, "http.route": ROUTE }) const received = yield* receiveSvixWebhook({ provider: "clerk", secret: env.CLERK_WEBHOOK_SECRET, request: req, }) if (received._tag === "rejected") return received.response const envelope = yield* decodeClerkEnvelope(received.body).pipe(Effect.option) if (Option.isNone(envelope)) { yield* Effect.annotateCurrentSpan({ "http.response.status_code": 400, "maple.webhook.outcome": "rejected", "maple.webhook.reason": "parse_rejected", }) return webhookText("Unrecognized payload", 400) } yield* Effect.annotateCurrentSpan({ "maple.webhook.event": envelope.value.type }) if (isClerkMembershipEvent(envelope.value.type)) { return yield* handleMembership(envelope.value.data, envelope.value.type, envelope.value.timestamp) } if (envelope.value.type === "user.deleted") { const payload = yield* decodeClerkUserDeleted(envelope.value.data).pipe(Effect.option) const rawId = Option.isSome(payload) ? payload.value.id : undefined // Clerk's delete envelope may legitimately omit the id. There is then no // user to sweep and no retry that can produce one, so a 400 would only // burn Svix's retry budget and end as a failed delivery nobody sees. // 200 and an error log: the sweep that did not run is on the record. if (Option.isSome(payload) && rawId === undefined) { yield* Effect.logError("Clerk user.deleted carried no user id; no revocation sweep ran") yield* Effect.annotateCurrentSpan({ "http.response.status_code": 200, "maple.webhook.outcome": "unresolvable_user", }) return webhookText("ok", 200) } const userId = rawId === undefined ? Option.none() : decodeUserId(rawId) if (Option.isNone(userId)) { yield* Effect.annotateCurrentSpan({ "http.response.status_code": 400, "maple.webhook.outcome": "parse_rejected", }) return webhookText("Unrecognized user payload", 400) } return yield* revoke(revocation.revokeUser(userId.value)) } if (envelope.value.type === "user.created") { const user = yield* decodeClerkUserCreated(envelope.value.data).pipe( Effect.tapError((error) => Effect.logInfo("Clerk user.created payload failed to decode").pipe( Effect.annotateLogs({ error: String(error) }), ), ), Effect.option, ) if (Option.isSome(user)) { yield* productEvents.track(signupCompletedEvent(user.value, envelope.value.timestamp)) yield* Effect.annotateCurrentSpan({ "maple.webhook.outcome": "handled" }) } else { yield* Effect.annotateCurrentSpan({ "maple.webhook.outcome": "parse_rejected" }) } } else { yield* Effect.annotateCurrentSpan({ "maple.webhook.outcome": "ignored" }) } yield* Effect.annotateCurrentSpan({ "http.response.status_code": 200 }) return webhookText("ok", 200) }) }) /** * The route with `MembershipRevocationService` still an open requirement, so a * test can drive the handler against a stub without a database. */ export const ClerkWebhookRoute = HttpRouter.use((router) => Effect.gen(function* () { const routeHandler = yield* handler yield* router.add("POST", ROUTE, routeHandler) }), ) export const ClerkWebhookRouter = ClerkWebhookRoute.pipe( // Provided here rather than in the HTTP graph: the sweep pulls in the edge // cache and the destination encryption key, and nothing else in the router // tree needs them. Layer.provide(MembershipRevocationService.layer), )