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:
- Clerk fires a
user.createdevent to/api/webhooks. - The handler verifies the svix signature with
CLERK_WEBHOOK_SECRET(rejects on missing/invalidsvix-id/svix-timestamp/svix-signatureheaders). - On a verified
user.created, it inserts ausersrow with the Clerk id, primary email, name — androleId: ROLES.STUDENT(the default tier;STUDENT = 2). It first checks for an existing row byclerkIdand 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 byclerkId(fast path). If none, it pulls the email off the Clerk session and adopts a row only if that row'sclerk_idis 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-fillsclerk_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:
| Constant | id | Meaning |
|---|---|---|
ROLES.ADMIN | 1 | Platform admin — full access to admin tooling |
ROLES.STUDENT | 2 | Default tier assigned by the webhook |
ROLES.GUEST | 3 | Unauthenticated-ish / minimal tier |
ROLES.QA | 4 | A label, not a gate (see below) |
ROLES.SUPER_ADMIN | 5 | Admin + money/role privileges |
Predicates live in src/lib/auth/roles.ts:
isAdminRoleId(roleId)— true for bothADMINandSUPER_ADMIN. A super-admin passes everywhere a plain admin does.isSuperAdminRoleId(roleId)— true only forSUPER_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:
| Value | Meaning |
|---|---|
tester | default — runs tests, files issues |
lead | cycle lead |
observer | read-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()inroles.ts.checkSuperAdminAccess()inaccess-control.ts— mirrorscheckAdminAccess()but denies a plain admin.requireSuperAdmin(handler)andrequireSuperAdminForRoute(handler)route wrappers inapi-middleware.ts— return 403 unless the effective user isSUPER_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:
| Wrapper | Gate | Failure |
|---|---|---|
createProtectedRoute(handler) | any authenticated user (via getUserAccess) | 401 |
requireAdmin(handler) | admin (or super-admin) — simple handler | 403 |
requireAdminSimple(handler) | admin — adds an AuthContext | 403 |
requireAdminForRoute(handler) | admin — route has params | 403 |
requireAdminWithValidation(handler, opts) | admin + Zod validation | 403 / 400 |
requireAdminOrCourseAccess(handler) | admin or course access | 403 |
requireAdminOrModuleAccess(handler) | admin or module access | 403 |
requireSuperAdmin(handler) | super-admin only | 403 |
requireSuperAdminForRoute(handler) | super-admin only — has params | 403 |
requireQuizSessionAccess(handler) | authenticated and owns the quiz session | 401 / 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 callsresolveApiKey(request, requiredScopes), then enforces that the cycle'sorganizationIdmatches the key's org (a key for org A can't touch org B's cycles) and that the key's optionalcycleIdsrestriction allows this cycle (apiKeyCanAccessCycle). API-key callers act asisAdmin: truewithrole: null;dbUserIdis the key's creator (used for reporter attribution). - Clerk path — delegates to
checkTestCycleAccess(cycleId)unchanged (admin ORtestersmembership), returning the per-cyclerole.
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()produceshk_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 (
hashApiKey→createHash("sha256")) inapi_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 shortkeyPrefixis stored for display. - Org-bound: every key has
orgId(FK toorganizations). It can reach cycles in that org only, optionally narrowed by a comma-joinedcycleIdsallow-list (empty = all cycles in the org). - Lifecycle:
isActiveflag (revoke without delete), optionalexpiresAt,rateLimit(default 60/min),lastUsedAtupdated best-effort on each call,createdByfor 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:readHelpers: 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 anadmin_impersonation_logrow. getActiveImpersonation(realUser)validates the cookie: the row must be non-ended and itsadmin_user_idmust 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/appand/apirequires a Clerk session. - i18n: landing routes (
/,/en,/kz,/ru) are handed tonext-intl'sintlMiddleware(locales from@/i18n/routing). - API-key bypass: any request with
Authorization: Bearer hk_live_…skips the Clerkauth.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 viaresolveApiKey/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
/apiand/trpc.
Quick reference — where each concern lives
| Concern | File |
|---|---|
Clerk middleware, public allowlist, hk_live_ bypass, i18n | src/middleware.ts |
Clerk user → users row (svix webhook) | src/app/api/webhooks/route.ts |
| Resolve current user, self-heal, impersonation | src/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 table | src/db/schema/_enums.ts |
users, admin_impersonation_log | src/db/schema/users.ts |
testers, TESTER_ROLES, api_keys scopes | src/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