Auth Module
JWT, Clerk sync, org management, invitations, and webhooks
The Auth module handles all identity, token, and organisation management. Source: packages/server/src/modules/auth/.
REST Endpoints
AuthController (auth.controller.ts)
All endpoints are prefixed with /api/v1/auth.
| Method | Path | Guards | Description |
|---|---|---|---|
| GET | /cli-auth | None | CLI auth step 1 — redirect to Clerk sign-in |
| GET | /cli-callback | None | CLI auth step 2 — exchange session for Cosmo JWT |
| POST | /exchange-token | None | Desktop/web — exchange Clerk JWT for Cosmo JWT |
| POST | /onboarding-complete | ClerkAuthGuard | Mark user's onboarding complete |
| POST | /welcome-seen | ClerkAuthGuard | Mark the first-run welcome modal dismissed (idempotent) |
| GET | /onboarding-state | ClerkAuthGuard | Read { onboardingCompleted, welcomeSeen } for this membership |
| POST | /seed-onboarding | ClerkAuthGuard | Seed org with sample teams + tasks (idempotent) |
| POST | /refresh | None | Refresh expired Cosmo JWT (7-day grace) |
| GET | /me | ClerkAuthGuard + OptionalOrg | Get authenticated user profile |
| POST | /webhook | None | Clerk webhook receiver (Svix-verified) |
OrgController (org.controller.ts)
All endpoints are prefixed with /api/v1/org.
| Method | Path | Guards | Description |
|---|---|---|---|
| GET | /members | ClerkAuthGuard + RolesGuard | List org members |
| PATCH | /members/:userId/role | @Roles('OWNER') | Change a member's role |
| DELETE | /members/:userId | @Roles('OWNER', 'ADMIN') | Remove a member |
| GET | /invites | @Roles('OWNER', 'ADMIN') | List pending invitations |
| POST | /invite | @Roles('OWNER', 'ADMIN') | Invite a user by email |
| POST | /invite/:id/resend | @Roles('OWNER', 'ADMIN') | Resend an invitation |
| DELETE | /invite/:id | @Roles('OWNER', 'ADMIN') | Revoke an invitation |
Auth Service (services/auth.service.ts)
Token Operations
| Method | Description |
|---|---|
mintCosmoToken(userId, orgId, orgRole) | Issue a 30-day signed JWT |
verifyCosmoToken(token) | Verify and decode; throws on invalid/expired |
refreshToken(token) | Re-issue JWT; allows up to 7 days after expiry |
verifyClerkToken(token) | Verify Clerk JWT, return { clerkUserId, clerkOrgId, clerkOrgRole } |
User & Org Provisioning
| Method | Description |
|---|---|
syncUserFromClerk(clerkUserId, email, name, avatarUrl) | Create or update User record |
syncOrgFromClerk(clerkId, name, slug) | Create or update Organization record |
syncMembership(clerkOrgId, clerkUserId, role) | Upsert OrgMember with mapped role |
getMembership(orgId, userId) | Fetch membership or null |
markOnboardingCompleted(orgId, userId) | Set onboardingCompleted = true |
markWelcomeSeen(orgId, userId) | Stamp welcomeSeenAt on first dismissal (idempotent) |
getOnboardingState(orgId, userId) | Return { onboardingCompleted, welcomeSeen } for the membership |
fetchClerkUser(clerkUserId) | Fetch user from Clerk Management API |
fetchFirstClerkOrg(clerkUserId) | Get user's first org from Clerk |
fetchClerkMembershipRole(orgId, userId) | Get role from Clerk (for refresh flows) |
Member Management
| Method | Description |
|---|---|
listMembers(orgId) | All members with user data |
updateMemberRole(orgId, userId, role) | Change role |
removeMember(orgId, userId) | Delete membership record |
Invitations
| Method | Description |
|---|---|
createInvite(orgId, email, role) | Generate invite with secure token (24h expiry) |
listInvites(orgId) | All pending (non-expired) invites |
resendInvite(inviteId, orgId, actingUserId) | Revoke the outgoing Clerk invitation (as actingUserId, resolved to a Clerk id and sent as requesting_user_id), mint a replacement, then update the existing row in place with the new token/expiry — never inserts a second row for the same email. Recreates the row if the organizationInvitation.revoked webhook races the revoke and deletes it first. Throws if Clerk reports the invitee already joined the org (and cleans up the now-dead local row in that case) |
revokeInvite(inviteId, orgId, actingUserId) | Revoke the invitation in Clerk (same requesting_user_id handling as above) before deleting the local row, so the emailed link stops working. Degrades gracefully — warns and still deletes locally — when Clerk isn't configured or the org has no Clerk linkage |
An invite that cannot land is rejected with a 409 Conflict whose message is
user-facing copy the client shows verbatim — an existing member of this org, an
invite already pending, or an account that already belongs to any org (see the
one-org rule below). Clerk's own refusal codes
(already_a_member_in_organization, duplicate_record) map to the same 409
rather than being swallowed into a 201 success.
One organization per user
assertCanProvisionOrg(userId, clerkOrgId) is the enforcement point, called from
POST /auth/exchange-token. A user who belongs to any org — in any role, not
only as its owner — is refused (403) when the org being provisioned is a different
one. Re-authenticating into the org they already belong to is always allowed, so
nobody is locked out of their own workspace. createInvite applies the same rule
up front so an admin is told before sending, rather than waiting on an acceptance
that the exchange would reject.
Onboarding Service (services/onboarding.service.ts)
seedOrgOnboarding(orgId, userId, data?) is the entry point called by POST /auth/seed-onboarding. It:
- Creates a shared team (
"Shared") and a personal team for the user. - Creates a shared workspace (
"Shared Workspace") in the shared team. - Seeds 6 sample tasks across the personal and shared workspaces.
The seeding is idempotent — it checks for an existing shared team before creating anything. A database transaction prevents race conditions from concurrent requests.
Webhook Handler (webhooks/webhook.handler.ts)
Handles Clerk webhooks verified with Svix. Registered at POST /api/v1/auth/webhook.
| Event | Action |
|---|---|
user.created | syncUserFromClerk(...) |
user.updated | syncUserFromClerk(...) |
user.deleted | Delete User record |
organization.created | syncOrgFromClerk(...) |
organization.updated | syncOrgFromClerk(...) |
organizationMembership.created | syncMembership(...) |
organizationMembership.updated | syncMembership(...) |
organizationMembership.deleted | removeMember(...) |
organizationInvitation.created | createInvite(...) |
organizationInvitation.accepted | Mark invite accepted |
organizationInvitation.revoked | revokeInvite(...) |
Auth Repository (repositories/auth.repository.ts)
All Prisma queries for auth data are delegated here:
- User CRUD (
findByClerkId,upsert,delete) - Org CRUD (
findByClerkId,upsert) - Membership queries (
findMembership,upsertMembership,deleteMembership,listByOrg) - Invite queries (
create,findById,findByOrg,findByToken,delete,updateInviteToken— replaces an invite's token/expiry in place for resend)
CLI Auth Flow
- CLI calls
GET /api/v1/auth/cli-auth?port={localPort}&state={random}. - Server redirects to Clerk hosted sign-in with a callback to
GET /api/v1/auth/cli-callback. - After Clerk auth, the callback exchanges the Clerk session for a Cosmo JWT.
- Server redirects to
http://localhost:{port}/callback?token=...&state=...— the CLI's local callback server (path is/callback, not/; includes thestateparameter for CSRF protection).
Desktop Auth Flow
- Desktop opens the web app's auth page in the system browser.
- Web app authenticates via Clerk, then calls
POST /api/v1/auth/exchange-tokenwith the Clerk JWT. - Server verifies the Clerk token, syncs user/org, issues a Cosmo JWT.
- Web app deep-links back to the desktop with
cosmo://auth?token=....