Sign-in and saved conversations
Who may use the chat, how they sign in with GitHub, what is stored about them, and how a conversation is saved and read back.
What
The dashboard is public. The chat is not: it calls a paid model, so it needs a GitHub sign-in, and only the GitHub usernames on an allowlist may use it. Each person has their own conversations, saved on the server, listed beside the chat, and their own spending cap.
# In Infisical (or the environment): GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET,
# BETTER_AUTH_SECRET (openssl rand -base64 32), VEKTOS_ALLOWED_GITHUB=vallo87
bun run db:migrate
infisical run -- bun run web # http://localhost:3000/chat → Sign in with GitHub| Piece | Where | Job |
|---|---|---|
| Better Auth | apps/web/src/lib/auth.ts, app/api/auth/[...all] | GitHub OAuth, sessions in Postgres |
| Allowlist | packages/chat/src/access.ts | VEKTOS_ALLOWED_GITHUB, comma-separated usernames |
| Conversations | packages/chat/src/conversations.ts | Create, own, list, load, save, delete |
| Pages | app/chat/page.tsx, app/chat/[id]/page.tsx, components/chat/shell.tsx | The checks, then the list and the chat |
The GitHub OAuth app's callback is http://localhost:3000/api/auth/callback/github. In production
it is the deployed address, set in GitHub and in BETTER_AUTH_URL.
Why
- Only the chat is behind sign-in. The dashboard is the demo; anyone may read it. The chat spends money, so it needs to know who is asking.
- GitHub, with an allowlist. The people who will use it have GitHub accounts, and the username is
a stable, readable name to list. The list is checked twice:
- When an account would be created. A Better Auth hook refuses a new user whose username is not listed, so a stranger who signs in leaves nothing in the database.
- On every request. The chat compares the session's username with the list each time, so a name taken off the list stops working at once. A session hook also refuses a new session for a user no longer listed.
- Better Auth, not a homemade session. Sessions, the OAuth state, CSRF on the auth routes and cookie handling are the parts where a small mistake is a security hole. Better Auth stores them in our Postgres through Drizzle, with tables we generated with its CLI and own as migrations.
- Nothing to change about yourself. Only GitHub sign-in is on: the email and password routes and
/update-userare disabled, so the stored username is always the one GitHub sent. - The GitHub token is encrypted. Better Auth keeps the OAuth access token; Vektos never uses it.
encryptOAuthTokensstores it encrypted with the server's secret. - The conversation id is the browser's, ownership is the server's. The page makes a UUID for a new conversation so it can show it at once. The first request with that id creates it for the signed-in person; a request with someone else's id is answered as if the conversation did not exist (404), so nobody can find out what ids are in use.
- The browser sends one question. The server reads the history from
chat_messagesand saves each answer as the AI SDK's UI message, tool results included. A follow-up uses those results without calling the tools again, and a browser cannot slip a made-up result into the history. - A cap per person. One person cannot use the whole chat's budget: $0.50 each by default, inside the $1 for everyone.
- No title model. A conversation's title is its first question, cut at a word. A model call to name it would cost money for nothing a reader needs.
Try it
bun test packages/chat/src/access # the allowlist
bun run check:db # conversations, ownership, the endpoint's checks
infisical run -- bun run webOpen /chat, sign in, ask, reload: the conversation is still there, and listed on the left. Open
another browser profile, sign in with an account that is not on the list: you are sent back with
"Sign-in did not complete", and auth_user has no row for it.
What the first real sign-in stored, checked in the database: one user, vallo87; one session,
valid for seven days; a GitHub token stored encrypted (no gho_ prefix); one conversation titled
with its first question, two messages whose parts include the results of explain_rule and
search_docs; and its three costs, each with the user and the conversation.
What is stored, and how to remove it
| Table | What | Removed when |
|---|---|---|
auth_user | GitHub id (as the account), name, email, avatar URL, username | by hand (see below) |
auth_session | a token, when it expires, the IP address and the browser's user agent | sign-out, or expiry after 7 days |
auth_account | the link to GitHub, the token (encrypted) and its scopes | with the user |
chat_conversations, chat_messages | the questions and answers, with tool results | "Delete conversation", or with the user |
chat_usage | cost of each model call, with the user and conversation | kept: the user and conversation become empty when deleted |
There is no "delete my account" button yet. To remove a person, delete their auth_user row: their
sessions, account link, conversations and messages go with it, and their costs stay without a name.
Extend it
- Another person. Add their GitHub username to
VEKTOS_ALLOWED_GITHUBand restart the app. - Another provider. Add it to
socialProvidersinlib/auth.ts, and decide what the allowlist compares (the allowlist readsgithubLogintoday). - A deployed app. Set
BETTER_AUTH_URLto its address, add that callback in the GitHub OAuth app, and use a newBETTER_AUTH_SECRET; tokens encrypted with the old one cannot be read.
Known gaps
- The username is read once. It is stored when the account is created. Someone who renames their GitHub account keeps the old name here until their user row is deleted.
- No account deletion in the interface, and no export of one's conversations.
- No rate limit beyond the caps. A listed person can ask as fast as they like until their cap.
- Better Auth's own routes are not covered by our tests. The tests drive the chat with a made-up user; the OAuth flow was tried by hand, once, with the real GitHub app.
- Stored answers include the model's reasoning parts. They are not shown, but they are kept.
Chat
A chat that answers from the benchmark's database and from the project's documents and two legal texts, with citations that are checked and a spending cap kept in the database.
Payload check
What the runner really sends to a model through the AI SDK, compared with the same loop written with the official SDKs. Offline, no key, no cost.