Two separate authentication surfaces exist in this app, plus one unified access-control model that both funnel into.
- Web session auth — Better Auth, cookie-based, for the browser UI.
- Git-over-HTTP auth — Basic Auth (PAT or username/password) for
git clone/fetch/push, since a git client can't do cookie-based sessions. RepositoryAccess— the single computation (src/server/repo-access.ts) that both of the above ultimately check against to decide what a given user can actually do with a given repository.
Configured in src/lib/auth.ts:
- Email/password auth, with required email verification — a user can't
log in until they've clicked the verification link (sent via Resend, see
src/lib/email.ts). - Sessions last 7 days (
expiresIn), refreshed if used within the last day (updateAge), with a 5-minute cookie cache to avoid re-validating on every request. - Password reset and email verification both send through Resend, wired into
Better Auth's
sendResetPassword/sendVerificationEmailhooks. usernameplugin adds a username field (3–30 chars) alongside email — usernames are what shows up in repo URLs and storage keys (see git-storage.md'sgetStorageOwnerKey).- Cookies: secure,
pushstack-prefixed, not shared cross-subdomain.
BETTER_AUTH_SECRET must never reach the browser bundle. src/lib/auth.ts
throws at import time if it's unset, which means importing it anywhere that
gets bundled client-side crashes the app in the browser instead of the server.
This is why src/lib/auth-session.ts's getSession server function uses a
dynamic import("@/lib/auth") inside the handler body rather than a
top-level import: TanStack Start's client-side RPC stub for a server function
keeps the module's top-level imports (it can't prove they're side-effect-free
to drop, even though the handler body itself gets swapped out client-side), so
a static top-level import of lib/auth.ts would still evaluate it in the
browser and throw. If you ever need the real auth instance inside a
createServerFn handler, follow this same dynamic-import pattern rather than
importing it at module scope.
getSession() also single-flights concurrent calls for the same request
cookie (inFlight map in auth-session.ts) — a single page load commonly
fires several server functions in parallel that each independently ask "who's
logged in," and without this each one would separately hit Better Auth's own
session validation for the identical cookie.
src/server/session.ts wraps this into the two functions almost everything
else in src/server/ calls:
getCurrentUserOptional()— returns the session user, ornull.getCurrentUser()— same, but throws"Unauthorized"if there's no session. Use this in any handler that requires a logged-in user; use the optional variant for anything that has a meaningful anonymous/public behavior (repo reads, issue lists, etc.).
A git clone/push request can't participate in a browser session, so every
git HTTP request is authenticated independently via authenticateGitRequest,
which tries, in order:
- Better Auth session cookie — if the request has a
cookieheader at all (a real git CLI request never does, so this is skipped entirely for CLI traffic, avoiding a wasted session-validation call). - Personal Access Token — HTTP Basic Auth where the password (or,
for compatibility, the username) starts with
ghp_. The token is hashed with SHA-256 and looked up by that hash (tokens.tokenHash) — the raw token is never stored. Plain SHA-256 (no bcrypt/argon2) is intentional and correct here: PATs are high-entropy random strings, not low-entropy human passwords, so they aren't vulnerable to the rainbow-table attacks password hashing guards against. - Username/password — falls back to verifying against the
accounttable's stored credential hash via Better Auth's ownverifyPassword. This path bypasses Better Auth's own rate limiter entirely (that one only wraps/api/auth/*requests), so it's rate-limited independently: 10 failed attempts per (case-insensitive) username/email within a 5-minute window locks that key out, tracked in agit_auth_attemptsDB table (not an in-process counter — see security.md's "Git password-auth rate limiting" for why a serverless deployment needs the DB-backed version). Only failed attempts count, so frequent legitimate re-auth (e.g. CI polling) never trips it.
Token scopes (repo:read/repo:write, or none = unrestricted) are checked
against the requested operation via hasRequiredTokenScope — a token scoped
to repo:write implicitly satisfies a repo:read requirement.
None of these three paths ever log the credential itself — only generic "auth failed" messages, so nothing sensitive ends up in server logs.
Every repository has:
- a visibility:
publicorprivate - an owner (the creating user)
- zero or more collaborators, each with a role:
read,write, oradmin
src/server/repo-access.ts collapses all of that into one RepositoryAccess
object per (repoId, userId) pair:
interface RepositoryAccess {
repository: ...;
collaboratorRole: "read" | "write" | "admin" | null;
role: "anonymous" | "read" | "write" | "admin" | "owner";
canRead: boolean;
canWrite: boolean;
canModerate: boolean;
canMergePullRequest: boolean;
}Resolution order (buildAccess): public + anonymous → read-only; private +
anonymous → no access; owner → full access; then falls through to the
collaborator role (admin/write/read collaborators get progressively
fewer permissions); anything else falls back to "can read only if public."
This is the only place in the codebase that should ever compute this. Every server function that touches a repository calls through one of:
getRepositoryAccess(repoId, userId)— the general case.getAccessForRepository(repository, userId)— when the caller already has the repository row in hand (e.g. fromdb.query.issues.findFirst({ with: { repository: true } })) — skips a redundant repo-row fetch.canReadRepo/canWriteRepo/canModerateRepo/canMergePullRequest— boolean convenience wrappers.requireReadAccess(repoId, userId)/requireWriteAccess(repoId, userId)— throw instead of returning a boolean; use these instead of hand-rolling a fetch-then-check block in a new handler.getRepoWithReadAccess/getRepoWithWriteAccess— fetch-and-check in one call, returning the repository row on success.
These are all broader-than-owner checks — any write collaborator passes
requireWriteAccess. Owner-only actions (deleting a repo, managing
collaborators) still need an explicit repo.ownerId !== user.id check on top;
there's no single helper for "owner only" because so few actions need it.
A single tree-page load fans out to getBranches/listFiles/getLastCommits/getCommits
in parallel, and each of them independently needs to answer "does this user
have access to this repo" — same (repoId, userId), computed redundantly 3-4x
concurrently, on top of whatever getRepositoryByName already resolved just
before them. repo-access.ts caches the resolved RepositoryAccess for 4
seconds and coalesces concurrent in-flight resolutions for the same key, so
that whole burst shares one DB round trip instead of each firing its own. The
TTL is intentionally short — this is a performance cache, not a correctness
cache. A revoked collaborator or a visibility flip should take effect within a
few seconds, not linger for a request's lifetime the way the long-lived git
object cache does.