auth: Registrierung, Login, Logout, per-user Sessions (Backend)

This commit is contained in:
OpenClaw Dev 2026-10-31 11:28:17 +01:00
parent c7b4acea02
commit 0ef39cffd6
12 changed files with 703 additions and 40 deletions

284
internal/server/auth.go Normal file
View file

@ -0,0 +1,284 @@
package server
import (
"context"
"crypto/subtle"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
"github.com/crypto/byteclaw/internal/store"
)
// Principal is the authenticated caller. Admin (static auth_token) sees all sessions.
type Principal struct {
UserID int64
Username string
Admin bool
token string
}
// Scope is the user filter for store queries.
func (p Principal) Scope() int64 {
if p.Admin {
return store.AllUsers
}
return p.UserID
}
// OwnerID is the user_id new sessions get.
func (p Principal) OwnerID() int64 {
if p.Admin {
return 0
}
return p.UserID
}
type ctxKey struct{}
func principal(ctx context.Context) Principal {
p, _ := ctx.Value(ctxKey{}).(Principal)
return p
}
const (
defaultTokenTTL = 30 * 24 * time.Hour
loginWindow = 15 * time.Minute
loginMaxFails = 10
)
// dummyHash keeps the timing of "unknown user" close to "wrong password".
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("byteclaw-dummy-password"), bcrypt.DefaultCost)
// limiter counts failed attempts per IP in a fixed window.
type limiter struct {
mu sync.Mutex
hits map[string][]time.Time
max int
win time.Duration
now func() time.Time
}
func newLimiter(max int, win time.Duration) *limiter {
return &limiter{hits: map[string][]time.Time{}, max: max, win: win, now: time.Now}
}
func (l *limiter) prune(key string) []time.Time {
cut := l.now().Add(-l.win)
h := l.hits[key]
i := 0
for i < len(h) && h[i].Before(cut) {
i++
}
h = h[i:]
if len(h) == 0 {
delete(l.hits, key)
} else {
l.hits[key] = h
}
return h
}
func (l *limiter) Blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.prune(key)) >= l.max
}
func (l *limiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
l.hits[key] = append(l.prune(key), l.now())
}
func (l *limiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
func clientIP(r *http.Request) string {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
func (s *Server) initAuth() {
s.authOnce.Do(func() {
if s.limiter == nil {
s.limiter = newLimiter(loginMaxFails, loginWindow)
}
if s.TokenTTL <= 0 {
s.TokenTTL = defaultTokenTTL
}
})
}
func bearer(r *http.Request) string {
if t, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
return strings.TrimSpace(t)
}
// Browsers cannot set headers on WebSocket upgrades: allow ?token= there only.
if r.URL.Path == "/api/ws" {
return r.URL.Query().Get("token")
}
return ""
}
// auth accepts the static admin token (constant-time) or a user token.
func (s *Server) auth(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := bearer(r)
var p Principal
switch {
case got == "":
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
case s.Token != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) == 1:
p = Principal{Admin: true, Username: "admin"}
default:
u, err := s.Store.UserByToken(r.Context(), got)
if err != nil {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
p = Principal{UserID: u.ID, Username: u.Username}
}
p.token = got
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, p)))
})
}
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
func validUsername(u string) bool {
if n := utf8.RuneCountInString(u); n < 3 || n > 32 {
return false
}
for _, c := range u {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') {
return false
}
}
return true
}
func validPassword(p string) bool {
// bcrypt only uses the first 72 bytes; reject longer input instead of silently truncating.
return utf8.RuneCountInString(p) >= 8 && len(p) <= 72
}
func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, code int) {
tok, exp, err := s.Store.IssueToken(r.Context(), u.ID, s.TokenTTL)
if err != nil {
s.storeErr(w, err)
return
}
writeJSON(w, code, map[string]any{"token": tok, "expires_at": exp, "user": map[string]any{"id": u.ID, "username": u.Username}})
}
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
s.initAuth()
if !s.AllowRegistration {
writeErr(w, http.StatusForbidden, "registration disabled")
return
}
ip := clientIP(r)
if s.limiter.Blocked(ip) {
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
}
var c credentials
if !decode(w, r, &c) {
return
}
c.Username = strings.TrimSpace(c.Username)
if !validUsername(c.Username) {
writeErr(w, http.StatusBadRequest, "username must be 3-32 characters (letters, digits, . _ -)")
return
}
if !validPassword(c.Password) {
writeErr(w, http.StatusBadRequest, "password must be 8-72 characters")
return
}
s.limiter.Fail(ip) // every registration counts against the IP budget
hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost)
if err != nil {
s.storeErr(w, err)
return
}
u, err := s.Store.CreateUser(r.Context(), c.Username, string(hash))
if errors.Is(err, store.ErrUserExists) {
writeErr(w, http.StatusConflict, "username already taken")
return
}
if err != nil {
s.storeErr(w, err)
return
}
s.issue(w, r, u, http.StatusCreated)
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
s.initAuth()
ip := clientIP(r)
if s.limiter.Blocked(ip) {
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
}
var c credentials
if !decode(w, r, &c) {
return
}
u, err := s.Store.UserByName(r.Context(), strings.TrimSpace(c.Username))
hash := dummyHash
if err == nil {
hash = []byte(u.PasswordHash())
} else if !errors.Is(err, store.ErrNotFound) {
s.storeErr(w, err)
return
}
if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil {
s.limiter.Fail(ip)
writeErr(w, http.StatusUnauthorized, "invalid username or password")
return
}
s.limiter.Reset(ip)
s.issue(w, r, u, http.StatusOK)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
p := principal(r.Context())
if !p.Admin {
if err := s.Store.RevokeToken(r.Context(), p.token); err != nil {
s.storeErr(w, err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
p := principal(r.Context())
writeJSON(w, 200, map[string]any{"id": p.UserID, "username": p.Username, "admin": p.Admin})
}
// owns writes 404 and returns false unless the caller may access session id.
func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool {
if err := s.Store.SessionOwnedBy(r.Context(), principal(r.Context()).Scope(), id); err != nil {
s.storeErr(w, err)
return false
}
return true
}

View file

@ -0,0 +1,200 @@
package server
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/crypto/byteclaw/internal/agent"
"github.com/crypto/byteclaw/internal/store"
"github.com/crypto/byteclaw/internal/tools"
)
func authSetup(t *testing.T, allowReg bool) (*httptest.Server, *store.Store, *Server) {
st, err := store.Open(filepath.Join(t.TempDir(), "s.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
s := &Server{Store: st, Token: tok, AllowRegistration: allowReg, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second,
Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}}
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
return ts, st, s
}
type authResp struct {
Token string `json:"token"`
User struct {
ID int64 `json:"id"`
Username string `json:"username"`
} `json:"user"`
}
func creds(u, p string) string {
b, _ := json.Marshal(map[string]string{"username": u, "password": p})
return string(b)
}
func registerUser(t *testing.T, base, u, p string) string {
t.Helper()
r := do(t, "POST", base+"/api/auth/register", creds(u, p), nil)
if r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Fatalf("register %s: %d %s", u, r.StatusCode, b)
}
var a authResp
json.NewDecoder(r.Body).Decode(&a)
if a.Token == "" || a.User.Username != u {
t.Fatalf("bad register response %+v", a)
}
return a.Token
}
func bearerHdr(t string) map[string]string { return map[string]string{"Authorization": "Bearer " + t} }
func TestRegisterLoginMeLogout(t *testing.T) {
ts, _, _ := authSetup(t, true)
t1 := registerUser(t, ts.URL, "alice", "supersecret")
if r := do(t, "POST", ts.URL+"/api/auth/register", creds("ALICE", "supersecret"), nil); r.StatusCode != 409 {
t.Fatalf("duplicate (case-insensitive) must be 409, got %d", r.StatusCode)
}
r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1))
var me map[string]any
json.NewDecoder(r.Body).Decode(&me)
if r.StatusCode != 200 || me["username"] != "alice" {
t.Fatalf("me: %d %v", r.StatusCode, me)
}
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrongpass"), nil); r.StatusCode != 401 {
t.Fatalf("wrong password: %d", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("nobody", "whatever1"), nil); r.StatusCode != 401 {
t.Fatalf("unknown user: %d", r.StatusCode)
}
r = do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil)
var a authResp
json.NewDecoder(r.Body).Decode(&a)
if r.StatusCode != 200 || a.Token == "" || a.Token == t1 {
t.Fatalf("login: %d", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/auth/logout", "", bearerHdr(a.Token)); r.StatusCode != 204 {
t.Fatalf("logout: %d", r.StatusCode)
}
if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(a.Token)); r.StatusCode != 401 {
t.Fatalf("revoked token must be 401, got %d", r.StatusCode)
}
if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)); r.StatusCode != 200 {
t.Fatalf("other token must stay valid, got %d", r.StatusCode)
}
}
func TestRegisterValidation(t *testing.T) {
ts, _, _ := authSetup(t, true)
for _, c := range []struct{ u, p string }{{"ab", "longenough"}, {strings.Repeat("a", 33), "longenough"}, {"bad name", "longenough"}, {"bob", "short"}} {
if r := do(t, "POST", ts.URL+"/api/auth/register", creds(c.u, c.p), nil); r.StatusCode != 400 {
t.Fatalf("%q/%q: want 400 got %d", c.u, c.p, r.StatusCode)
}
}
}
func TestRegistrationDisabled(t *testing.T) {
ts, _, _ := authSetup(t, false)
if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "supersecret"), nil); r.StatusCode != 403 {
t.Fatalf("want 403 got %d", r.StatusCode)
}
}
func TestAuthRequired(t *testing.T) {
ts, _, _ := authSetup(t, true)
for _, p := range []string{"/api/sessions", "/api/info", "/api/auth/me", "/api/providers", "/api/ws"} {
if r := do(t, "GET", ts.URL+p, "", nil); r.StatusCode != 401 {
t.Fatalf("%s without token: %d", p, r.StatusCode)
}
if r := do(t, "GET", ts.URL+p, "", bearerHdr("bc_invalid")); r.StatusCode != 401 {
t.Fatalf("%s bad token: %d", p, r.StatusCode)
}
}
if r := do(t, "GET", ts.URL+"/api/sessions?token="+tok, "", nil); r.StatusCode != 401 {
t.Fatalf("query token must only work for ws, got %d", r.StatusCode)
}
// static admin token still works
if r := do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tok)); r.StatusCode != 200 {
t.Fatalf("static token: %d", r.StatusCode)
}
}
func TestPerUserIsolation(t *testing.T) {
ts, st, _ := authSetup(t, true)
legacy, _ := st.CreateSession(t.Context(), "legacy")
ta := registerUser(t, ts.URL, "alice", "supersecret")
tb := registerUser(t, ts.URL, "bob_2", "supersecret")
r := do(t, "POST", ts.URL+"/api/sessions", `{"title":"bob's"}`, bearerHdr(tb))
var bs store.Session
json.NewDecoder(r.Body).Decode(&bs)
list := func(tk string) []store.Session {
var l []store.Session
json.NewDecoder(do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tk)).Body).Decode(&l)
return l
}
if l := list(ta); len(l) != 1 || l[0].ID != legacy.ID {
t.Fatalf("first user must adopt legacy session, got %+v", l)
}
if l := list(tb); len(l) != 1 || l[0].ID != bs.ID {
t.Fatalf("bob sees %+v", l)
}
for _, m := range []struct{ method, path string }{{"GET", ""}, {"GET", "/messages"}, {"DELETE", ""}, {"POST", "/cancel"}, {"GET", "/summary"}} {
if r := do(t, m.method, ts.URL+"/api/sessions/"+bs.ID+m.path, "", bearerHdr(ta)); r.StatusCode != 404 {
t.Fatalf("alice %s %s on bob's session: %d", m.method, m.path, r.StatusCode)
}
}
if r := do(t, "PATCH", ts.URL+"/api/sessions/"+bs.ID, `{"title":"x"}`, bearerHdr(ta)); r.StatusCode != 404 {
t.Fatalf("rename foreign: %d", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/chat", `{"session_id":"`+bs.ID+`","text":"hi"}`, bearerHdr(ta)); r.StatusCode != 404 {
t.Fatalf("chat into foreign session: %d", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, bearerHdr(ta)); r.StatusCode != 200 {
t.Fatalf("own chat: %d", r.StatusCode)
}
if l := list(tb); len(l) != 1 {
t.Fatalf("alice's new chat leaked to bob: %+v", l)
}
if l := list(tok); len(l) != 3 {
t.Fatalf("admin must see all sessions, got %d", len(l))
}
}
func TestLoginRateLimit(t *testing.T) {
ts, _, s := authSetup(t, true)
registerUser(t, ts.URL, "alice", "supersecret")
s.limiter.Reset("127.0.0.1")
for i := 0; i < loginMaxFails; i++ {
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrong-pass"), nil); r.StatusCode != 401 {
t.Fatalf("attempt %d: %d", i, r.StatusCode)
}
}
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 429 {
t.Fatalf("want 429 after %d failures, got %d", loginMaxFails, r.StatusCode)
}
// window expiry unblocks
s.limiter.now = func() time.Time { return time.Now().Add(loginWindow + time.Second) }
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 200 {
t.Fatalf("after window: %d", r.StatusCode)
}
}
func TestWSQueryToken(t *testing.T) {
ts, _, _ := authSetup(t, true)
tk := registerUser(t, ts.URL, "alice", "supersecret")
r := do(t, "GET", ts.URL+"/api/ws?token="+tk, "", map[string]string{"Connection": "Upgrade", "Upgrade": "websocket", "Sec-WebSocket-Version": "13", "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ=="})
if r.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("ws with query token: %d", r.StatusCode)
}
}

View file

@ -90,8 +90,7 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
return
}
id := r.PathValue("id")
if _, err := s.Store.GetSession(r.Context(), id); err != nil {
s.storeErr(w, err)
if !s.owns(w, r, id) {
return
}
var a providers.Active
@ -110,13 +109,15 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) cancel(w http.ResponseWriter, r *http.Request) {
if !s.owns(w, r, r.PathValue("id")) {
return
}
writeJSON(w, 200, map[string]bool{"cancelled": s.Agent.Cancel(r.PathValue("id"))})
}
func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := s.Store.GetSession(r.Context(), id); err != nil {
s.storeErr(w, err)
if !s.owns(w, r, id) {
return
}
sm, err := s.Agent.SummarizeNow(r.Context(), id)
@ -133,6 +134,9 @@ func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) {
if !s.owns(w, r, r.PathValue("id")) {
return
}
sm, err := s.Store.LatestSummary(r.Context(), r.PathValue("id"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {

View file

@ -3,8 +3,7 @@ package server
import (
"context"
"crypto/subtle"
"encoding/json"
"encoding/json"
"errors"
"log/slog"
"net/http"
@ -35,6 +34,13 @@ type Server struct {
Info Info
// Providers enables runtime provider switching (optional).
Providers *providers.Manager
// AllowRegistration enables POST /api/auth/register.
AllowRegistration bool
// TokenTTL is the lifetime of user tokens (default 30 days).
TokenTTL time.Duration
authOnce sync.Once
limiter *limiter
}
type Info struct {
@ -47,7 +53,12 @@ type Info struct {
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
s.initAuth()
mux.HandleFunc("POST /api/auth/register", s.register)
mux.HandleFunc("POST /api/auth/login", s.login)
api := http.NewServeMux()
api.HandleFunc("POST /api/auth/logout", s.logout)
api.HandleFunc("GET /api/auth/me", s.me)
api.HandleFunc("GET /api/info", s.info)
api.HandleFunc("GET /api/providers", s.listProviders)
api.HandleFunc("PUT /api/providers/active", s.setActiveProvider)
@ -64,8 +75,10 @@ func (s *Server) Handler() http.Handler {
api.HandleFunc("GET /api/sessions/{id}/messages", s.listMessages)
api.HandleFunc("POST /api/chat", s.chat)
api.HandleFunc("GET /api/ws", s.ws)
mux.Handle("/api/", s.originCheck(s.auth(api)))
return securityHeaders(mux)
mux.Handle("/api/", s.auth(api))
h := http.Handler(mux)
h = s.originCheck(h)
return securityHeaders(h)
}
func securityHeaders(h http.Handler) http.Handler {
@ -76,18 +89,6 @@ func securityHeaders(h http.Handler) http.Handler {
})
}
// auth requires "Authorization: Bearer <token>" (constant-time compare).
func (s *Server) auth(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) != 1 {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
h.ServeHTTP(w, r)
})
}
// OriginAllowed: requests without Origin (native apps, curl) pass; browser
// requests must come from the same host or an explicitly configured origin.
func (s *Server) OriginAllowed(r *http.Request) bool {
@ -158,7 +159,7 @@ func (s *Server) storeErr(w http.ResponseWriter, err error) {
}
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
list, err := s.Store.ListSessions(r.Context(), 200)
list, err := s.Store.ListSessionsFor(r.Context(), principal(r.Context()).Scope(), 200)
if err != nil {
s.storeErr(w, err)
return
@ -173,7 +174,7 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
if r.ContentLength != 0 && !decode(w, r, &b) {
return
}
ss, err := s.Store.CreateSession(r.Context(), b.Title)
ss, err := s.Store.CreateSessionFor(r.Context(), principal(r.Context()).OwnerID(), b.Title)
if err != nil {
s.storeErr(w, err)
return
@ -182,6 +183,9 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) getSession(w http.ResponseWriter, r *http.Request) {
if !s.owns(w, r, r.PathValue("id")) {
return
}
ss, err := s.Store.GetSession(r.Context(), r.PathValue("id"))
if err != nil {
s.storeErr(w, err)
@ -197,6 +201,9 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
if !decode(w, r, &b) {
return
}
if !s.owns(w, r, r.PathValue("id")) {
return
}
if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil {
s.storeErr(w, err)
return
@ -205,6 +212,9 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
if !s.owns(w, r, r.PathValue("id")) {
return
}
if err := s.Store.DeleteSession(r.Context(), r.PathValue("id")); err != nil {
s.storeErr(w, err)
return
@ -214,8 +224,7 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := s.Store.GetSession(r.Context(), id); err != nil {
s.storeErr(w, err)
if !s.owns(w, r, id) {
return
}
msgs, err := s.Store.Messages(r.Context(), id, 0)
@ -256,14 +265,13 @@ func (s *Server) startSession(ctx context.Context, req chatReq) (string, error)
// ensureSession returns an existing session id or creates one titled after the message.
func (s *Server) ensureSession(ctx context.Context, id, text string) (string, error) {
if id != "" {
_, err := s.Store.GetSession(ctx, id)
return id, err
return id, s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), id)
}
title := strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
if utf8.RuneCountInString(title) > 48 {
title = string([]rune(title)[:48]) + "…"
}
ss, err := s.Store.CreateSession(ctx, title)
ss, err := s.Store.CreateSessionFor(ctx, principal(ctx).OwnerID(), title)
return ss.ID, err
}
@ -337,7 +345,7 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
send(agent.Event{Type: "pong"})
continue
case "cancel":
if req.SessionID == "" || !s.Agent.Cancel(req.SessionID) {
if req.SessionID == "" || s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), req.SessionID) != nil || !s.Agent.Cancel(req.SessionID) {
send(agent.Event{Type: "error", Text: "nothing to cancel", Session: req.SessionID})
}
continue