Authentication and access control
Better Auth sessions, git-over-HTTPS tokens and the single RepositoryAccess computation.
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-HTTPS auth — personal access tokens over Basic Auth for
git clone/fetch/push. RepositoryAccess— the single computation (apps/web/src/server/repo-access.ts) both funnel into.
Web session auth (Better Auth)
Configured in apps/web/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
apps/web/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. - The
usernameplugin adds a username (3–30 chars,STORAGE_NAME_RE). It's required at sign-up and can never change: it appears in repo URLs and is the first segment of every storage key (see git-storage.md).databaseHooksenforce both. - Cookies: secure,
pushstack-prefixed, not shared cross-subdomain.
BETTER_AUTH_SECRET must never reach the browser bundle. apps/web/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 apps/web/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.
apps/web/src/server/session.ts wraps this into the two functions almost everything
else in apps/web/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.).
Git-over-HTTPS auth (git-auth.ts)
A git client can't hold a browser session, so authorizeGitRequest(request, repo, needsWrite) authenticates every git request on its own, with
personal access tokens only:
- The token (
pst_…) goes in the Basic-auth password or username slot. It's hashed with SHA-256 and looked up by hash; the raw token is never stored. Plain SHA-256 is right for high-entropy random tokens. - No passwords: a password over git can't be scoped or revoked on its own, and verifying one per request would need its own rate limiter.
- No cookies: a cookie-authenticated git endpoint is a CSRF target.
- No credentials → anonymous: reads of public repos succeed; anything else
gets
401withWWW-Authenticate, so the client prompts. - Bad or expired token →
401. A valid token without the needed scope (repo:read/repo:write, write implies read) or role →403.
Credentials are never logged.
RepositoryAccess — the single access-control computation
Every repository has:
- a visibility:
publicorprivate - an owner (the creating user)
- zero or more collaborators, each with a role:
read,write, oradmin
apps/web/src/server/repo-access.ts collapses all of that into one RepositoryAccess
object per (repoId, userId) pair:
interface RepositoryAccess {
repository: ...;
role: "anonymous" | "read" | "write" | "admin" | "owner";
canRead: boolean;
canWrite: boolean;
canModerate: boolean;
canMergePullRequest: boolean;
}buildAccess picks the role (owner, else the collaborator role, else
anonymous) and reads its flags from one PERMISSIONS table; anonymous can
read only public repositories.
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.requireWriteAccess(repoId, userId)— throws instead of returning.readWithAccess(label, repoId, fn)— the read-handler shape: perf context, optional session, read check (a private repo answers like a missing one), thenfn(repo).writeWithAccess(repoId, fn(repo, user))— the same for writes, requiring a session and write access.requireOwner(repoId, userId, message)— owner-only actions (rename, delete, collaborators, repack).
Why there's a short-TTL cache in front of this
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.