A person was in exactly one project, chosen at approval, and could not use the gateway before that. People belong to several teams and to none, so: - memberships: a person is a member of any number of organizations, teams and projects, or of none; a member of a team or organization may use every project in it. users join/leave, and the people page adds and takes out from either side. Approval no longer needs a project. - personal keys: a key may have an owner and no project. /profile asks what a new key spends against; keys create --for does the same. auth.personal sets default limits for personal use or turns it off. - limits for people: --scope user holds a person across every key, project and the chat page; --per-member on an org, team or project gives each member their own share there. Resolution folds the tree, each per-member limit for the key's owner, and the owner's own, each in its own pool; the quotas page shows and sets both. - the chat page asks what to spend against and checks it per message. - usage records who spent it, so a person's budget survives a restart, personal use and chat included; restore no longer drops rows with no project. Leaving a project revokes the keys made there unless another membership still reaches it. A personal key keeps no threads or notes, which are per project, and is refused rather than dropped into the anonymous pool. Migration 0010 turns each person's project into a membership, adds usage.user_id and backfills it, and makes api_keys.project_id nullable. SQLite cannot drop NOT NULL, so the table is rebuilt; that needs foreign keys off, which only works outside a transaction, so migrations marked for it run on one connection with foreign_key_check before commit. Tested on a populated SQLite store with claims, and on a copy of the cluster's PostgreSQL store: 5 people became members, 17 keys and 3 claims intact, 145 of 577 usage rows and all 69 chat rows attributed. The PostgreSQL store tests, including new membership cases, pass on 16. users move is gone; join and leave replace it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
91 lines
2.6 KiB
Go
91 lines
2.6 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// ChatModel is one entry in the chat page's model picker.
|
|
type ChatModel struct {
|
|
Name string
|
|
// Simulated marks a model that returns synthetic text rather than
|
|
// inference output. The picker says so, because the two are
|
|
// indistinguishable from the outside until the reply arrives.
|
|
Simulated bool
|
|
// OnDemand marks a model with nothing running that loads when asked. It is
|
|
// offered anyway: the first message is what asks, so hiding it until it
|
|
// runs meant it could never be started from here.
|
|
OnDemand bool
|
|
}
|
|
|
|
// ChatContext is one thing the chat page can spend against: a project, or the
|
|
// person themselves.
|
|
type ChatContext struct {
|
|
Value string
|
|
Label string
|
|
}
|
|
|
|
type chatData struct {
|
|
Nav Nav
|
|
Contexts []ChatContext
|
|
Context string
|
|
User store.User
|
|
Models []ChatModel
|
|
CSRF string
|
|
Version string
|
|
Now string
|
|
}
|
|
|
|
// Chat renders the in-browser chat for one signed-in person.
|
|
func Chat(user store.User, models []ChatModel, contexts []ChatContext, current, version string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
render(w, "chat", chatData{
|
|
Nav: navFrom(r, "chat"),
|
|
Contexts: contexts, Context: current,
|
|
User: user, Models: models, CSRF: csrfFor(r),
|
|
Version: version, Now: time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Guard runs a handler only for a signed-in, approved account.
|
|
//
|
|
// Exported because the chat lives in the router — it needs the endpoint
|
|
// registry and the request path — while the session machinery lives here. The
|
|
// alternative was a second copy of the session check next to the one thing that
|
|
// must never have two copies.
|
|
//
|
|
// Its POSTs carry the CSRF token in a header rather than a form field, because
|
|
// they are JSON. A header is a stronger check than a field: a cross-site form
|
|
// post cannot set one at all.
|
|
func (p *AccountPages) Guard(
|
|
next func(w http.ResponseWriter, r *http.Request, user store.User),
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
sess, user, ok := p.session(r)
|
|
if !ok {
|
|
if r.Method == http.MethodGet {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
} else {
|
|
http.Error(w, "not signed in", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
token := r.Header.Get("X-CSRF-Token")
|
|
if token == "" {
|
|
token = r.FormValue("csrf")
|
|
}
|
|
if !p.accounts.CheckCSRF(sess, token) {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
next(w, p.withNav(r, user), user)
|
|
}
|
|
}
|