Architecture
The backend
convex/auth.ts— the whole Better Auth configuration: providers, plugins, trusted origins, rate limiting, account linking.createAuthOptionsis split out fromcreateAuthbecause the component directory needs the options without env-var access.convex/betterAuth/— a local install of the Better Auth component. The packaged component ships a fixed schema with no passkey, wallet or API-key tables, so this repo owns the schema instead.convex/lib/— the plugins that aren't in Better Auth: Sign In With Solana, Mullvad-style account numbers, the shared demo account and its lockdown,linking.ts(adding a password to an account that arrived without one), andstatus.ts(which credentials are set, so the card can say "needs setup"). Plusapple.ts, which mints Apple's client secret, andnotify.ts, which sends via Resend / Mobile Message or falls back to logging.convex/http.ts— the two files a third party fetches (Apple's domain association, the WebAuthn related-origins list), plus/apps/registerand/apps/unregister.convex/apps.ts+convex/lib/apps.ts— the app registry: who's allowed in, from which origins, using which methods.convex/lib/methods.ts— the one path→method map, shared bylastLoginMethodand the per-app allow-list so they can't disagree.
The frontend
src/routes/— the site./and/docs/*prerender to static HTML;/sign-inand/accountare client-only, because the session is a cookie jar in localStorage and there is nothing a server render could know about it.src/auth/providers.ts— display metadata per method;src/auth/panels.tsx— the matching behaviour;src/auth/methods.tswires the two together by id.src/auth/AuthProvider.tsx— Convex and Better Auth, mounted only by the two routes that sign someone in, so the landing page and the docs don't download the auth client.src/account/Account.tsx— signed-in view: profile, passkeys and agent API keys;src/account/SignInMethods.tsx— linking extra credentials onto the account you're already signed in as.src/lib/rememberedAccounts.ts— the returning-account chooser.
Tests
Two projects.
unit is plain node and covers the logic where a bug is a security bug and there's no UI to notice it through: Solana signature verification, the demo lockdown's path matcher, account-number generation, the registration secret and body validation, the path→method map, and the WebAuthn site-limit arithmetic.
component renders the sign-in card and account page in a real browser against mocked endpoints.
The component tests render the same *.fixture.tsx files the explorer shows,
through the same src/uaight.preview.tsx, so a state you can look at is a state
that's covered.
The explorer is uaight, a Vite plugin
rather than a second tool: it mounts at /uaight on the app's own dev server, so
bun dev is the whole toolchain and there is no second process, no second port
and no second Vite config to drift out of sync. A fixture resolves modules
exactly as the app does because it is the app's server doing the resolving.
A fixture is a component in a named state; src/testing/MockApi.tsx gives it the
endpoints and localStorage it needs. Anything worth asserting about goes in a
colocated *.test.tsx that imports the fixture.
src/uaight.preview.tsx is where the providers and the stylesheet live. uaight
runs it inside the preview frame's realm — the only place a fixture's CSS and
React context can land — and the component tests import it directly.
It also imports src/lib/auth, which is what configures the package's
authClient live binding. Components import that binding directly, so without
the side-effect import every card fixture throws "AussieAuth has no client yet"
before it draws anything.
bun run uaight # a deployable static explorer, to dist-uaight/
Fixtures that mock the network need a served frame
MockApi intercepts with MSW's service worker, and a service worker only
registers in a document loaded from a real http(s) URL. uaight through
0.0.1-canary.1 wrote its preview frame into about:blank, which has no URL,
so worker.start() rejected and the four SignInCard fixtures carrying
handlers rendered nothing at all.
Fixed upstream in canary.2, which serves the frame's document at
/@uaight/preview in dev and as preview.html in a static build. MockApi
throws with the reason rather than rendering nothing, because the earlier
behaviour was a blank frame whose only evidence was a console line.
bun run uaight needs canary.2 for a second reason: the static build used to
run this project's TanStack Start plugins, and Start's manifest plugin counts
entries — it saw the explorer's document and the renderer chunk and died on
multiple entries detected. uaight now leaves a meta-framework's plugins out of
that build and prints which ones, since the explorer is a different application
from the site.
The component tests were never affected: vitest browser mode serves them from a real URL.
Linting and formatting
One tool each, both Rust:
bun run lint # tsc --noEmit && oxlint --type-aware
bun run format # oxfmt .
--type-aware runs the rules that need a type checker — no-floating-promises
is the one that earns its keep here, since a dropped auth promise fails silently.
It goes through oxlint-tsgolint, which requires TypeScript 7; that's why the
typescript dependency is pinned to ~7.0.2 rather than left to float.
.oxlintrc.json carries a note against every disabled rule. Most of them are off
because they disagree with Convex's own conventions — _id is not a dangling
underscore anyone chose, and the sequential awaits in convex/apps.ts are
sequential on purpose.
Coming back to a remembered account
The sign-in card lists accounts this browser has used before and gets you back into one without a prompt.
crossDomainClient can't be handed a session cookie on a .convex.site response,
so it keeps the whole cookie jar as JSON in localStorage. Signing in copies that
jar into aussieauth.accounts alongside your name and avatar; clicking the
account puts it back and asks the server whether it still holds.
If it does you're in with no round trip to any provider. If it's expired we re-run
the method lastLoginMethod recorded — a silent redirect for a social provider,
or the right panel with your address already filled in.
Which is why Sign out doesn't revoke: it clears the local jar and leaves the session alive, so the account stays one click away. Sign out everywhere is the real thing, and so is the ✕ next to a remembered account.
A gotcha worth knowing
frosted-ui's Button wraps Base UI, which defaults native buttons to
type="button". Inside a <form> you must pass type="submit" explicitly or
onSubmit never fires.
Questions
Why is the Better Auth component installed locally rather than as a package?
The packaged component ships a fixed schema with no passkey, wallet or API-key
tables. Owning convex/betterAuth/ is what makes those methods possible.
Why doesn't signing out revoke the session? So the account stays one click away on the next visit. "Sign out everywhere" revokes; so does removing a remembered account.
Where do I add a new sign-in method?
Three places: register the Better Auth plugin in convex/auth.ts, add a row to
src/auth/providers.ts, and add a panel to src/auth/panels.tsx. If it should
be blocked for the demo account, add it to LOCKED in convex/lib/demo.ts too.