Hackorda Docs

Authentication

How HackOrda decides who you are (identity) and what you can do (authorization). Two independent concerns, two independent mechanisms:

  • Identity comes from Clerk for humans, or from an API key (hk_live_…) for programmatic / agent callers.
  • Authorization is a two-axis model: a global system role (users.roleId → roles) crossed with a per-cycle role (testers.role). Neither axis alone tells the whole story.

There is no NextAuth and there is no users.role enum — roles are a foreign key into a roles reference table. If you see a doc or snippet referencing either, it is stale.


1. Identity: Clerk

Clerk owns sessions, sign-in/sign-up UI, password/social login, and JWTs. The app never stores passwords. Server code reads the current session with auth() / currentUser() from @clerk/nextjs/server.

Clerk user → local users row

Every Clerk identity has a mirror row in our users table, keyed by users.clerkId (varchar, unique). The two are linked by the svix webhook in src/app/api/webhooks/route.ts:

  1. Clerk fires a user.created event to /api/webhooks.
  2. The handler verifies the svix signature with CLERK_WEBHOOK_SECRET (rejects on missing/invalid svix-id / svix-timestamp / svix-signature headers).
  3. On a verified user.created, it inserts a users row with the Clerk id, primary email, name — and roleId: ROLES.STUDENT (the default tier; STUDENT = 2). It first checks for an existing row by clerkId and skips if found, so the webhook is idempotent.

user.updated and user.deleted are received but currently only logged (TODO stubs).

Self-healing the linkage

An admin can pre-assign someone to a cycle by email before that person has ever signed in — so the users row may exist with a NULL clerk_id. src/lib/auth/resolve-user.ts repairs this on first sign-in:

  • findOrRelinkUserByClerkSession() first looks up the row by clerkId (fast path). If none, it pulls the email off the Clerk session and adopts a row only if that row's clerk_id is NULL — it never overwrites an existing non-null linkage (that would let an attacker hijack an account by re-using a deleted email). On adoption it back-fills clerk_id.

This resolver is the single source of truth for "who is the current DB user".


2. The two-axis authorization model

Access is the intersection of two orthogonal roles.

Axis A — system role (users.roleId → roles)

users.roleId is a non-null FK into the roles reference table. The numeric ids are fixed constants in src/db/schema/_enums.ts:

ConstantidMeaning
ROLES.ADMIN1Platform admin — full access to admin tooling
ROLES.STUDENT2Default tier assigned by the webhook
ROLES.GUEST3Unauthenticated-ish / minimal tier
ROLES.QA4A label, not a gate (see below)
ROLES.SUPER_ADMIN5Admin + money/role privileges

Predicates live in src/lib/auth/roles.ts:

  • isAdminRoleId(roleId)true for both ADMIN and SUPER_ADMIN. A super-admin passes everywhere a plain admin does.
  • isSuperAdminRoleId(roleId) — true only for SUPER_ADMIN.

The async wrappers (isAdmin, requireAdmin, isSuperAdmin, requireSuperAdmin, getUserRole) resolve the Clerk session, read roleId, and apply those predicates. getUserRole collapses the numeric tiers down to the legacy 'admin' | 'student' string shape so older === 'admin' callers keep working — super-admins map to 'admin', and super-admin-ness is queried separately via the isSuperAdmin* helpers.

Axis B — per-cycle role (testers.role)

Belonging to a test cycle is a separate row in the testers table (src/db/schema/test-cycles.ts). Its role column is one of TESTER_ROLES:

ValueMeaning
testerdefault — runs tests, files issues
leadcycle lead
observerread-only participant

A testers row scopes a user to one cycle (unique(testCycleId, userId)) and does not elevate global access.

QA is a label, the testers row is the gate

ROLES.QA exists as a system-role tier, but it is not what grants access to a cycle's data. The real gate is membership: whether a testers row exists for (testCycleId, userId). checkTestCycleAccess is explicit:

// src/lib/auth/test-cycle-auth.ts
if (isAdminRoleId(user.roleId)) {
  return { hasAccess: true, dbUserId: user.id, isAdmin: true, role: null };
}
const [member] = await db.select({ role: testers.role })
  .from(testers)
  .where(and(eq(testers.testCycleId, testCycleId), eq(testers.userId, user.id)));
if (!member) return { hasAccess: false, reason: 'Not a member of this test cycle' };
return { hasAccess: true, dbUserId: user.id, isAdmin: false, role: member.role };

So: admins (axis A) get every cycle; everyone else needs a testers row (axis B), and the returned role is that row's per-cycle role — independent of their system role.


3. Super-admin separation

Plain admin and super_admin diverge for money and role actions. The sensitive operations gate on SUPER_ADMIN specifically:

  • isSuperAdminRoleId(roleId) / isSuperAdmin() / requireSuperAdmin() in roles.ts.
  • checkSuperAdminAccess() in access-control.ts — mirrors checkAdminAccess() but denies a plain admin.
  • requireSuperAdmin(handler) and requireSuperAdminForRoute(handler) route wrappers in api-middleware.ts — return 403 unless the effective user is SUPER_ADMIN.

A plain admin can run cycles and tooling; only a super-admin can take the money/role-changing actions guarded by these wrappers.


4. Route protection helpers

API routes don't re-implement auth — they wrap handlers in one of the helpers from src/lib/auth/api-middleware.ts. The real exports:

WrapperGateFailure
createProtectedRoute(handler)any authenticated user (via getUserAccess)401
requireAdmin(handler)admin (or super-admin) — simple handler403
requireAdminSimple(handler)admin — adds an AuthContext403
requireAdminForRoute(handler)admin — route has params403
requireAdminWithValidation(handler, opts)admin + Zod validation403 / 400
requireAdminOrCourseAccess(handler)admin or course access403
requireAdminOrModuleAccess(handler)admin or module access403
requireSuperAdmin(handler)super-admin only403
requireSuperAdminForRoute(handler)super-admin only — has params403
requireQuizSessionAccess(handler)authenticated and owns the quiz session401 / 403

These delegate to the checks in access-control.ts: getUserAccess, checkAdminAccess, checkSuperAdminAccess, checkCourseAccess, checkModuleAccess. The first three resolve the effective user (see §7) via resolveCurrentEffectiveDbUser, so an admin who is impersonating a tester correctly fails an admin check.

Cycle-scoped routes

Cycle endpoints use checkTestCycleAccess(testCycleId) (Clerk-only) or — when a route must accept either auth method — the dual-auth resolver in §5.

resolveCurrentDbUser / resolveCurrentEffectiveDbUser (re-exported through test-cycle-auth.ts) give a route the current DB user row when it needs the user's id for attribution.


5. Dual-auth: resolveCycleAccess

Cycle routes that agents call programmatically accept both a Clerk session and an API key through one entry point — resolveCycleAccess(request, cycleId, requiredScopes):

const access = await resolveCycleAccess(request, cycleId, ['issues:read']);
if (!access.ok) return NextResponse.json({ error: access.error }, { status: access.status });
// access.dbUserId, access.isAdmin, access.via ('clerk' | 'api_key'), access.role, access.orgId
  • API-key path — when isApiKeyRequest(request) is true, it calls resolveApiKey(request, requiredScopes), then enforces that the cycle's organizationId matches the key's org (a key for org A can't touch org B's cycles) and that the key's optional cycleIds restriction allows this cycle (apiKeyCanAccessCycle). API-key callers act as isAdmin: true with role: null; dbUserId is the key's creator (used for reporter attribution).
  • Clerk path — delegates to checkTestCycleAccess(cycleId) unchanged (admin OR testers membership), returning the per-cycle role.

The denial result carries the right HTTP status (401 / 403 / 429) so the route stays a one-liner.


6. API keys

For CI scripts, MCP servers, and AI agents that have no browser session. Defined in src/db/schema/api-keys.ts and src/lib/auth/api-key-auth.ts.

  • Format: Authorization: Bearer hk_live_<random>generateApiKey() produces hk_live_ + 64 hex chars (32 random bytes).
  • Storage: the raw key is shown once at creation and never stored. The DB holds only a SHA-256 hash (hashApiKeycreateHash("sha256")) in api_keys.key_hash (unique). Not bcrypt — the key already has 256 bits of entropy, so a fast hash is fine and lookup is a direct equality match. A short keyPrefix is stored for display.
  • Org-bound: every key has orgId (FK to organizations). It can reach cycles in that org only, optionally narrowed by a comma-joined cycleIds allow-list (empty = all cycles in the org).
  • Lifecycle: isActive flag (revoke without delete), optional expiresAt, rateLimit (default 60/min), lastUsedAt updated best-effort on each call, createdBy for the audit trail.

resolveApiKey(request, required) validates: header present and prefixed, key exists and isActive, not expired, and has all required scopes (missing scopes → 403).

Scopes (API_KEY_SCOPES)

The full set, <resource>:<action>, stored comma-joined in api_keys.scopes:

cycles:read     cycles:write
issues:read     issues:write    issues:triage
runs:write
payouts:read    payouts:write
analytics:read
ai:write
admin:read

Helpers: apiKeyHasScope(ctx, scope) and apiKeyCanAccessCycle(ctx, cycleId).


7. Admin impersonation

Admins can run a "view as user" session. resolve-user.ts implements it:

  • A signed, httpOnly cookie (hk_impersonate_session, IMPERSONATION_COOKIE) carries the UUID of an admin_impersonation_log row.
  • getActiveImpersonation(realUser) validates the cookie: the row must be non-ended and its admin_user_id must match the real current admin — so the cookie is useless to anyone else.
  • resolveCurrentEffectiveDbUser() returns the target user while impersonating, so every route transparently sees the tester's view. While impersonating, checkAdminAccess() returns "not admin" (the effective user has the tester's role) — locking the admin out of admin tooling until they stop. resolveRealCurrentDbUser() ignores impersonation and is used by the start/stop and banner-status endpoints.

The admin_impersonation_log table (src/db/schema/users.ts) records each start→stop window with adminUserId, targetUserId, timestamps, IP, and user-agent for audit.


8. Middleware

src/middleware.ts wires Clerk + i18n and the API-key bypass.

export default clerkMiddleware(async (auth, request) => {
  if (isLandingRoute(request)) return intlMiddleware(request);          // i18n
  const authHeader = request.headers.get('authorization');
  if (authHeader?.startsWith('Bearer hk_live_')) return NextResponse.next();  // API-key bypass
  if (!isPublicRoute(request)) await auth.protect();                   // Clerk gate
  return NextResponse.next();
});
  • Public allowlist (isPublicRoute): /, the locale roots /en /kz /ru, /sign-in(.*), /sign-up(.*), /sso-callback(.*), /api/webhooks(.*), /api/health(.*), /api/invitations(.*), /terms, /privacy. Everything else under /app and /api requires a Clerk session.
  • i18n: landing routes (/, /en, /kz, /ru) are handed to next-intl's intlMiddleware (locales from @/i18n/routing).
  • API-key bypass: any request with Authorization: Bearer hk_live_… skips the Clerk auth.protect() check and is allowed through. This is not a weakening — without it, auth.protect() would rewrite the request to a 404 before the handler runs, breaking every API-key call. The route still validates the key, org, and scopes via resolveApiKey / resolveCycleAccess (§5–6). A Clerk session is something an API-key caller can never satisfy, so the only thing skipped is a check it would always fail.
  • Matcher: runs on all routes except Next internals and static assets, and always on /api and /trpc.

Quick reference — where each concern lives

ConcernFile
Clerk middleware, public allowlist, hk_live_ bypass, i18nsrc/middleware.ts
Clerk user → users row (svix webhook)src/app/api/webhooks/route.ts
Resolve current user, self-heal, impersonationsrc/lib/auth/resolve-user.ts
Role predicates (isAdminRoleId, isSuperAdminRoleId, …)src/lib/auth/roles.ts
Access checks (checkAdminAccess, checkSuperAdminAccess, …)src/lib/auth/access-control.ts
Route wrappers (createProtectedRoute, requireAdmin, …)src/lib/auth/api-middleware.ts
Cycle membership gate (checkTestCycleAccess)src/lib/auth/test-cycle-auth.ts
Dual-auth resolver (resolveCycleAccess)src/lib/auth/cycle-access.ts
API keys (resolveApiKey, generateApiKey, SHA-256, scopes)src/lib/auth/api-key-auth.ts
ROLES, roles tablesrc/db/schema/_enums.ts
users, admin_impersonation_logsrc/db/schema/users.ts
testers, TESTER_ROLES, api_keys scopessrc/db/schema/test-cycles.ts, src/db/schema/api-keys.ts

Environment variables

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
CLERK_WEBHOOK_SECRET=whsec_...   # verifies the svix user.created webhook

On this page