Files
llmgw/internal/store/users.go
T
Ilker ManapandClaude Opus 5 de21649ad6
CI / test (push) Successful in 1m13s
Add an in-browser chat, without putting a key in the browser
So someone can try a model without setting up a client, and — more often —
tell whether a problem is the gateway or their code.

The page carries a session, and the server turns that into the identity an API
key would have produced, resolved from the person's project. The request then
goes down the ordinary path by being handed to the same handler /v1 uses:
admission, model allowlist, rate limits, budget, accounting. There is exactly
one request path, so there is no second place for its rules to drift — and
without that, a playground is a hole in every limit the rest of the gateway
enforces. The allowlist test posts a forbidden model and expects 403 for that
reason.

The body is rebuilt rather than forwarded, so the page cannot set fields it has
no business setting: a stream:false that would hang the reader, a max_tokens
large enough to occupy a replica for an hour. One turn is capped at 512 tokens.

The picker offers what is actually served and allowed, not what is catalogued,
because offering a model with no replica produces a 503 the reader cannot
interpret.

Inline vanilla JavaScript, for the same reason the charts are hand-drawn SVG.
It streams, because on CPU inference ten seconds is normal and a chat that
hangs and then dumps a paragraph feels broken. Replies are written with
textContent throughout: model output and user input have no business being
parsed as markup. Its POSTs carry the CSRF token in a header, which a
cross-site form cannot set at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:05:30 +02:00

297 lines
9.8 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"strings"
"time"
)
// UserState is where an account is in its life.
type UserState string
// The account states. A new account is pending until an operator approves it,
// which is what keeps a public signup form from being a way in.
const (
UserPending UserState = "pending"
UserActive UserState = "active"
UserSuspended UserState = "suspended"
)
// ErrEmailTaken says an address already has an account.
//
// It is returned to callers that need to know — the CLI — and deliberately not
// surfaced to a signup form, where it would confirm which addresses have
// accounts here.
var ErrEmailTaken = errors.New("that address already has an account")
// User is a person.
type User struct {
ID string
Email string
Name string
PasswordHash string
State UserState
// ProjectID is assigned at approval and decides which limits the person's
// keys inherit. Empty while pending.
ProjectID string
CreatedAt time.Time
ApprovedAt time.Time
ApprovedBy string
LastLoginAt time.Time
// IsAdmin lets this account see the cluster dashboard. It is not a role
// system: one bit, and everything that changes the cluster stays on the CLI.
IsAdmin bool
}
// CanSignIn reports whether this account may open a session.
func (u User) CanSignIn() bool { return u.State == UserActive }
// NormalizeEmail is applied at every boundary, so two accounts cannot differ
// only in case — which would be two accounts to the database and one to every
// human who looks at them.
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
const userColumns = `id, email, name, password_hash, state, COALESCE(project_id, ''),
created_at, approved_at, approved_by, last_login_at, is_admin`
// CreateUser records a new, unapproved account.
func (s *SQLite) CreateUser(ctx context.Context, u User) error {
u.Email = NormalizeEmail(u.Email)
_, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, email, name, password_hash, state, project_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Email, u.Name, u.PasswordHash, string(u.State),
nullString(u.ProjectID), millis(u.CreatedAt))
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return ErrEmailTaken
}
return err
}
// UserByEmail finds an account by its address.
func (s *SQLite) UserByEmail(ctx context.Context, email string) (User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE email = ?`, NormalizeEmail(email)))
}
// UserByID finds an account.
func (s *SQLite) UserByID(ctx context.Context, id string) (User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE id = ?`, id))
}
// Users lists accounts, optionally filtered by state.
func (s *SQLite) Users(ctx context.Context, state UserState) ([]User, error) {
query := `SELECT ` + userColumns + ` FROM users`
var args []any
if state != "" {
query += ` WHERE state = ?`
args = append(args, string(state))
}
query += ` ORDER BY created_at`
return queryAll(ctx, s.db, query, args, func(rows *sql.Rows) (User, error) {
return s.scanUser(rows)
})
}
// ApproveUser activates an account and places it in a project.
//
// The project is required: an approved account with nowhere to put its keys
// would pass every check and then fail at the first key it tried to make.
func (s *SQLite) ApproveUser(ctx context.Context, id, projectID, by string, at time.Time) error {
if projectID == "" {
return errors.New("approving an account needs a project for its keys")
}
res, err := s.db.ExecContext(ctx,
`UPDATE users SET state = ?, project_id = ?, approved_at = ?, approved_by = ?
WHERE id = ? AND state = ?`,
string(UserActive), projectID, millis(at), by, id, string(UserPending))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetUserState suspends or reinstates an account.
func (s *SQLite) SetUserState(ctx context.Context, id string, state UserState) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET state = ? WHERE id = ?`, string(state), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetAdmin grants or withdraws access to the cluster dashboard.
//
// Withdrawing does not close their sessions: the page is read-only, so the
// worst a stale session buys is a few more seconds of looking. Suspending the
// account is what closes sessions, and that is the lever for someone who should
// not be here at all.
func (s *SQLite) SetAdmin(ctx context.Context, id string, admin bool) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET is_admin = ? WHERE id = ?`, admin, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchLogin records a successful sign-in.
func (s *SQLite) TouchLogin(ctx context.Context, id string, at time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`,
millis(at), id)
return err
}
// KeysForUser lists the keys a person made for themselves.
func (s *SQLite) KeysForUser(ctx context.Context, userID string) ([]Key, error) {
return queryAll(ctx, s.db,
`SELECT `+keyColumns+` FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
[]any{userID}, func(rows *sql.Rows) (Key, error) { return scanKey(rows) })
}
// CreateUserKey issues a key owned by a person.
func (s *SQLite) CreateUserKey(ctx context.Context, userID string, k Key) error {
if len(k.Hash) == 0 {
return errors.New("store: a key needs a hash")
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO api_keys (`+keyColumns+`, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.ID, k.Hash, k.Prefix, k.Name, k.ProjectID,
nullString(k.Band), nullString(k.RoutingProfile),
millis(k.CreatedAt), nullMillis(k.ExpiresAt), nullMillis(k.RevokedAt),
nullMillis(k.LastUsedAt), userID)
return err
}
// RevokeUserKey revokes a key only if it belongs to this person.
//
// The ownership check is in the statement rather than in a prior read: two
// queries would leave a window where a key changes hands between them, and more
// importantly it would be one forgotten check away from letting anyone revoke
// anyone's key by id.
func (s *SQLite) RevokeUserKey(ctx context.Context, userID, keyID string, at time.Time) error {
res, err := s.db.ExecContext(ctx,
`UPDATE api_keys SET revoked_at = ?
WHERE id = ? AND user_id = ? AND revoked_at IS NULL`,
millis(at), keyID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLite) scanUser(sc scanner) (User, error) {
var u User
var state string
var created int64
var approved, lastLogin sql.NullInt64
var approvedBy sql.NullString
if err := sc.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &state, &u.ProjectID,
&created, &approved, &approvedBy, &lastLogin, &u.IsAdmin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
return User{}, err
}
u.State = UserState(state)
u.CreatedAt = fromMillis(created)
u.ApprovedAt = fromNullMillis(approved)
u.ApprovedBy = approvedBy.String
u.LastLoginAt = fromNullMillis(lastLogin)
return u, nil
}
// SetPassword replaces an account's password hash.
//
// It does not close sessions; that is the caller's decision, because the two
// reasons to change a password want different things. Someone rotating theirs
// wants their other devices signed out. An operator resetting a forgotten one
// wants the same, but also has nobody to keep signed in.
func (s *SQLite) SetPassword(ctx context.Context, userID, hash string) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`,
hash, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// AllProjects lists every project with the path that identifies it.
//
// For the approval form, which has to offer somewhere to put a new account's
// keys. Walking the tree in the page would mean three round trips per render.
func (s *SQLite) AllProjects(ctx context.Context) ([]ProjectPath, error) {
return queryAll(ctx, s.db, `
SELECT p.id, o.name || ' / ' || t.name || ' / ' || p.name
FROM projects p
JOIN teams t ON t.id = p.team_id
JOIN organizations o ON o.id = t.org_id
ORDER BY 2`, nil, func(rows *sql.Rows) (ProjectPath, error) {
var p ProjectPath
return p, rows.Scan(&p.ID, &p.Path)
})
}
// ProjectPath is a project and where it sits in the tenancy tree.
type ProjectPath struct {
ID, Path string
}
// ResolveProject returns the limits an account inherits from its project.
//
// The same walk as Resolve, minus the key. A person signed in to the chat page
// has no key in their hand — the key was shown once and is theirs to keep — so
// their identity there is their project, and it must be resolved the same way
// or the page would be a hole in the limits everything else obeys.
func (s *SQLite) ResolveProject(ctx context.Context, projectID string) (Effective, error) {
var e Effective
row := s.db.QueryRowContext(ctx,
`SELECT p.id, t.id, o.id
FROM projects p
JOIN teams t ON t.id = p.team_id
JOIN organizations o ON o.id = t.org_id
WHERE p.id = ?`, projectID)
if err := row.Scan(&e.ProjectID, &e.TeamID, &e.OrgID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Effective{}, ErrNotFound
}
return Effective{}, err
}
// Widest first, so a narrower scope can only tighten what it inherits.
for _, step := range []struct {
scope Scope
id string
}{{ScopeOrg, e.OrgID}, {ScopeTeam, e.TeamID}, {ScopeProject, e.ProjectID}} {
l, err := s.LimitsFor(ctx, step.scope, step.id)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return Effective{}, err
}
e.tighten(l)
}
return e, nil
}