Hackorda Docs
Flows

F 01 Sign In

Status: ๐ŸŸข ready to test Owner: Adilet Last updated: 2026-06-06 Last verified on production: โ€”

Goal

A user authenticates with Clerk โ€” via Google OAuth or email + password โ€” and reaches /app. The first time anyone signs up, Clerk fires a user.created webhook that creates the matching users row (global role STUDENT). On every later request, resolveCurrentDbUser/resolve-user.ts maps the live Clerk session back to that users row.

Auth provider is Clerk, not NextAuth. There is no next-auth dependency, no /api/auth/[...nextauth] route, and no accounts / sessions / verificationTokens tables. Clerk holds the credentials and the session; our DB holds a thin users mirror keyed by clerk_id.

Actors

  • Primary โ€” any visitor (becomes a STUDENT on first sign-up; may later be promoted to ADMIN / QA / SUPER_ADMIN by changing users.roleId).
  • Secondary โ€” the Clerk webhook (svix-signed user.created POST to /api/webhooks) that creates the users row out-of-band; and any admin who may have pre-seeded a users row by email before the user ever signed in (the self-heal path in resolve-user.ts relinks it).

Preconditions

  • Clerk is configured (publishable + secret keys), and a webhook endpoint pointing at /api/webhooks with CLERK_WEBHOOK_SECRET set. Without the webhook, a brand-new Clerk user has a session but no users row, so resolveCurrentDbUser returns null and /app/* API calls 401/redirect.
  • For Google OAuth, the Google account is reachable; Clerk brokers the redirect to /sso-callback.
  • For a returning sign-in, the users row already exists (created on a prior sign-up's webhook, or pre-seeded by an admin with clerk_id = NULL).

Trigger

Either:

  • The visitor clicks "Sign in" / "Sign up" and lands on /sign-in or /sign-up, or
  • The visitor hits any protected route (e.g. /app/issues) while signed out. clerkMiddleware calls auth.protect(), which โ€” because the route is not in the public matcher โ€” rewrites the request to a 404 rather than redirecting to a login page. (Public routes: /, locale roots /en|/kz|/ru, /sign-in*, /sign-up*, /sso-callback*, /api/webhooks*, /api/health*, /api/invitations*, /terms, /privacy.)

Happy path โ€” sign up (first ever) with email + password

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1User/sign-upEnter email + password, submitClerk client signUp.create({ emailAddress, password })โ€”Clerk creates a pending user; status missing_requirementsโ€”
2User/sign-up (OTP step)Enter 6-digit codesignUp.prepareEmailAddressVerification โ†’ attemptEmailAddressVerification({ code })โ€”email verified; Clerk session created via setActiveโ€”
3Clerkโ€” (async webhook)Clerk POSTs user.created to /api/webhooksPOST /api/webhooks (svix-verified)INSERT INTO users (clerk_id, email, first_name, last_name, role_id = ROLES.STUDENT=2)row idempotently skipped if clerk_id already presentauth.signup
4App/approuter.push("/app") after setActivefirst authed request โ†’ resolveCurrentDbUserโ€” (or self-heal UPDATE โ€” see N-path)dashboard rendersauth.login

Steps 3 and 4 race: the redirect to /app can land before the webhook finishes. If the users row isn't there yet, findOrRelinkUserByClerkSession returns null for that first request. See Open questions.

Happy path โ€” sign up / sign in with Google (OAuth)

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1User/sign-in or /sign-upClick "Continue with Google"authenticateWithRedirect({ strategy: "oauth_google", redirectUrl: "/sso-callback", redirectUrlComplete: "/app" })โ€”redirect to Google consentโ€”
2Google โ†’ ClerkGoogle consentApproveback to /sso-callback; handleRedirectCallback({ afterSignInUrl, afterSignUpUrl })on new user only: user.created webhook โ†’ INSERT INTO users (โ€ฆ role_id=STUDENT)Clerk session establishedauth.signup (new) / auth.login
3App/app/sso-callback resolves, pushes to /appresolveCurrentDbUser on first authed requestself-heal UPDATE if a pre-seeded email row had NULL clerk_iddashboard rendersauth.login

Happy path โ€” returning sign in with email + password

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1User/sign-inEnter email + password, submitsignIn.create({ strategy: "password", identifier, password })โ€”on status === "complete", setActive({ session })โ€”
2App/approuter.push("/app")resolveCurrentDbUser resolves existing row by clerk_idโ€”dashboard rendersauth.login

Session โ†’ DB user resolution (every authed request)

resolveCurrentDbUser (via findOrRelinkUserByClerkSession in src/lib/auth/resolve-user.ts) is the single source of truth for "who is the current user":

  1. auth() yields the Clerk userId; if absent โ†’ null (not signed in).
  2. Look up users by clerk_id (fast path) โ†’ return it.
  3. Self-heal: if no clerk_id match, read the email off the Clerk session and look up a users row with that email AND clerk_id IS NULL. If found, UPDATE users SET clerk_id = <session> and return it. A row already linked to a different Clerk id is never overwritten (prevents account hijack by email reuse).
  4. If neither matches โ†’ null (Clerk session exists but no mirror row yet).

resolveCurrentEffectiveDbUser wraps this and additionally applies admin impersonation (see F-03); routes that need the real signed-in user use resolveRealCurrentDbUser.

Acceptance criteria

  • AC-1 โ€” Given a signed-out visitor, when they request a protected route like /app/issues, then the middleware (auth.protect() on a non-public route) returns 404 โ€” not a redirect to a sign-in page.
  • AC-2 โ€” Given a brand-new Clerk user (first sign-up via email or Google), when the user.created webhook is delivered to /api/webhooks, then exactly one users row is inserted with role_id = ROLES.STUDENT (2), clerk_id = the Clerk user id, and email = the primary email.
  • AC-3 โ€” Given a user.created webhook for a clerk_id that already has a users row, when it is processed, then no duplicate row is inserted (responds 200 "User already exists").
  • AC-4 โ€” Given a users row that an admin pre-seeded by email with clerk_id = NULL, when that person signs in via Clerk with the matching email, then resolve-user.ts relinks the row (sets clerk_id) and returns it โ€” without creating a second row.
  • AC-5 โ€” Given a users row whose clerk_id is already set to id A, when a different Clerk session id B presents the same email, then the row is not relinked (no UPDATE), and resolution returns null for B.
  • AC-6 โ€” Given a valid email + password, when the user submits the sign-in form and Clerk returns status === "complete", then setActive establishes the session and the user lands on /app.
  • AC-7 โ€” Given an invalid password, when the user submits, then the Clerk error's longMessage/message is shown inline and no session is set.
  • AC-8 โ€” Given a request to /api/webhooks with missing or invalid svix-id / svix-timestamp / svix-signature headers, then it is rejected with 400 and no DB write occurs.
  • AC-9 โ€” Given an API request carrying Authorization: Bearer hk_live_โ€ฆ, when it hits a non-public API route, then the middleware lets it through (skips the Clerk session check) so the route's own key validation runs.

Test data / fixtures

  • A Clerk DEV instance (never prod) with two test users wired in tests/e2e/fixtures/auth.ts: a Tester and an Admin. Each must also have a matching users row (created by the webhook on first sign-in โ€” sign in once manually at /sign-in to seed, per tests/e2e/README.md).
  • Env vars: CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, E2E_TESTER_EMAIL / _PASSWORD, E2E_ADMIN_EMAIL / _PASSWORD, CLERK_WEBHOOK_SECRET (for webhook tests).
  • For the self-heal AC (AC-4/AC-5): an admin-seeded users row with clerk_id = NULL and a known email.

Negative paths

#ScenarioExpected behavior
N-1Signed-out user navigates to /app/issuesMiddleware auth.protect() rewrites to 404 (no redirect, no sign-in page)
N-2Wrong password at /sign-inClerk throws; form shows longMessage/message (e.g. "Password is incorrect"); no session, no DB write
N-3Sign-up email already registered in ClerksignUp.create throws; first Clerk error message shown; user stays on /sign-up
N-4Webhook POST with missing svix headers400 "Error occurred -- no svix headers", no insert
N-5Webhook POST with bad signature400 "Error occured during webhook verification", no insert
N-6CLERK_WEBHOOK_SECRET unset500 "Webhook secret not configured", no insert
N-7New Clerk user reaches /app before webhook landsresolveCurrentDbUser returns null; authed API/page can't resolve the user until the row exists (race โ€” see Open questions)
N-8Email row already linked to a different clerk_id, same email re-presentedSelf-heal declines to relink (account-hijack guard); resolves to null
N-9OAuth consent canceled / /sso-callback errorshandleRedirectCallback catch โ†’ router.push("/sign-in"); no session

Manual QA checklist

  • Signed out, navigate to /app/issues โ†’ page returns 404 (confirm it is a 404, not a redirect to /sign-in)
  • Go to /sign-up, register a brand-new email + password โ†’ enter the 6-digit OTP โ†’ land on /app
  • In the DB, confirm a new users row exists with role_id = 2 (STUDENT) and the correct clerk_id + email
  • Sign out (Clerk), then sign back in at /sign-in with the same email + password โ†’ land on /app, no duplicate users row created
  • Sign in / sign up with Continue with Google โ†’ bounce through /sso-callback โ†’ land on /app
  • (Self-heal) Pre-seed a users row by email with clerk_id = NULL, then sign in via Clerk with that email โ†’ confirm the row's clerk_id got populated (check the [auth-self-heal] server log) and no second row appeared
  • Enter a wrong password at /sign-in โ†’ inline error shown, still on /sign-in
  • POST to /api/webhooks without svix headers โ†’ 400

Automated test outline

Playwright specs live in tests/e2e/flows/; auth is wired via the @clerk/testing fixtures in tests/e2e/fixtures/auth.ts (testerPage / adminPage, using clerkSetup + setupClerkTestingToken). Webhook + resolver assertions are better expressed as unit tests on the pure logic.

// tests/e2e/flows/sign-in.spec.ts
test.describe("F-01 sign-in (Clerk)", () => {
  test("unauthenticated /app/issues returns 404 (not redirect)", async ({ page }) => {
    // 1. fresh context, no session
    // 2. goto /app/issues
    // 3. assert response status 404
  });
  test("email/password happy path lands on /app", async ({ testerPage }) => { โ€ฆ });
  test("google oauth happy path", async ({ page }) => { /* requires Clerk test OAuth */ });
  test("wrong password shows inline error", async ({ page }) => { โ€ฆ });
});

// webhook + resolver โ€” pure/unit (Vitest)
describe("user.created webhook", () => {
  it("inserts users row with roleId = ROLES.STUDENT", โ€ฆ);
  it("is idempotent for an existing clerk_id", โ€ฆ);
  it("400s on missing svix headers", โ€ฆ);
});
describe("resolveCurrentDbUser self-heal", () => {
  it("relinks a NULL clerk_id row by email", โ€ฆ);
  it("refuses to relink a row already bound to another clerk_id", โ€ฆ);
});

Code references

  • Pages: src/app/(auth)/sign-in/[[...sign-in]]/page.tsx, src/app/(auth)/sign-up/[[...sign-up]]/page.tsx, src/app/(auth)/sso-callback/page.tsx, src/app/(auth)/layout.tsx
  • UI: src/components/auth/sign-in-form.tsx, src/components/auth/sign-up-form.tsx, src/components/auth/oauth-buttons.tsx, src/components/auth/auth-layout.tsx
  • Middleware: src/middleware.ts โ€” clerkMiddleware, public-route matcher, auth.protect() (unauth /app/* โ†’ 404 rewrite), Bearer hk_live_ API-key passthrough
  • Webhook: src/app/api/webhooks/route.ts โ€” svix-verified Clerk user.created handler that inserts the users row (roleId: ROLES.STUDENT)
  • Session โ†’ DB user: src/lib/auth/resolve-user.ts โ€” findOrRelinkUserByClerkSession, resolveRealCurrentDbUser, resolveCurrentEffectiveDbUser (impersonation-aware)
  • Schema: src/db/schema/users.ts (users.clerkId, users.email, users.roleId), src/db/schema/_enums.ts (ROLES โ†’ STUDENT = 2, roles table)
  • Tests: tests/e2e/fixtures/auth.ts (@clerk/testing token flow), tests/e2e/flows/, tests/e2e/README.md

Events emitted (proposed)

Once telemetry is wired (see Feature Matrix ยง4):

  • auth.signup โ€” fired when the user.created webhook inserts a new users row. Payload: { user_id, clerk_id, method: 'google' | 'password' }.
  • auth.login โ€” fired on a successful session establishment (setActive / resolved session). Payload: { user_id, method: 'google' | 'password' }.

Open questions / known gaps

  • Webhook race on first sign-up. The client redirects to /app as soon as setActive succeeds, but the users row is created asynchronously by the user.created webhook. A first authed request can arrive before the row exists, so resolveCurrentDbUser briefly returns null. No on-demand "ensure user row" fallback exists yet โ€” consider a lazy upsert in resolve-user.ts, or gating /app on row existence.
  • Sign-out is not specified here. Clerk's signOut() clears the session; there is no custom sign-out route or auth.logout server event today.
  • user.updated / user.deleted webhooks are stubs in src/app/api/webhooks/route.ts โ€” email/name changes in Clerk are not mirrored to users, and deletions leave the row in place. Document or implement before relying on users email staying in sync with Clerk.
  • No 2FA / magic-link surfaced; needs_second_factor is handled only as an error string in the sign-in form.

On this page