diff --git a/README.md b/README.md index a1414a1..2795a1b 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ See `config.example.toml` for a ready-to-edit starting point. | `addr` | `LARD_ADDR` | `:7477` | listen address | | `db` | `LARD_DB` | `~/.config/lard/lard.db` | sqlite path (sessions, facts, registry) | | `memory_dir` | `LARD_MEMORY_DIR` | `~/.config/lard/memory` | subject files | +| `multi_user` | `LARD_MULTI_USER` | `false` | one isolated store per authenticated identity | +| `data_dir` | `LARD_DATA_DIR` | `~/.config/lard/users` | where tenant directories live | +| `primary_user` | `LARD_PRIMARY_USER` | | identity owning requests with no OAuth identity | | `llm.base_url` | `LARD_HYPER_BASE_URL` | `https://hyper.charm.land` | OpenAI-compatible endpoint for consolidation | | `llm.model` | `LARD_MODEL` | `deepseek-v4-flash` | consolidation model | | `llm.api_key` | `LARD_HYPER_API_KEY` | | API key for consolidation (falls back to `HYPER_API_KEY`) | @@ -94,6 +97,52 @@ See `config.example.toml` for a ready-to-edit starting point. | `consolidate.after` | `LARD_CONSOLIDATE_AFTER` | `5m` | quiet period before a pass; `off` to disable | | `consolidate.max_wait` | `LARD_CONSOLIDATE_MAX_WAIT` | `30m` | cap on that wait during constant uploads | +## multi-user + +Off by default: one server, one memory. Turn it on and every authenticated +identity gets its own SQLite database and its own directory of subject files +under `data_dir`. Nothing crosses between them, and no query in the store layer +knows users exist. + +```toml +multi_user = true +data_dir = "" # ~/.config/lard/users +primary_user = "https://you.example" + +[auth] +mode = "oauth" # the identity comes off the token +``` + +Tenant directories are named `-`, derived from the identity's +`me` url: + +``` +users/ + dunkirk-sh-8f21c0a3b7de/ + lard.db + memory/{profile.md,areas/,topics/,people/} +``` + +Notes worth knowing before you flip it: + +- **`oauth` mode is what makes this work.** `token` and `none` put no identity + on a request, so every caller would land on `primary_user`. lard warns at + boot if you do that, and refuses to start if `primary_user` is also unset. +- **Your existing memory is adopted, not stranded.** On the first multi-user + boot, an existing `db` and `memory_dir` are *moved* into `primary_user`'s + tenant (wal and shm files included). It happens once and never overwrites a + tenant that already exists. Turning `multi_user` back off does not move it + back, so lard refuses to start rather than quietly serving a fresh empty + database; the error names the directory to move. +- **Consolidation is per user.** Each tenant gets its own quiet timer, so one + person uploading an afternoon of sessions doesn't reset anyone else's clock. +- **`auth.allowed_users` still gates the door.** Multi-user decides where an + accepted caller's memory lives, not who is accepted. Leave the allowlist + empty and anyone your authorization server vouches for gets a tenant. +- Tenants stay open for the life of the process, one SQLite connection each. + That is the right trade for a homelab; it is not a design for thousands of + users. + ## auth `token` mode is a shared secret,`oauth` mode makes lard an OAuth 2.1 protected resource in front of any authorization server that supports introspection ([rfc 7662]) and serves OAuth metadata ([rfc 8414]). lard serves diff --git a/cmd/lard/main.go b/cmd/lard/main.go index 94a992a..965113b 100644 --- a/cmd/lard/main.go +++ b/cmd/lard/main.go @@ -21,6 +21,7 @@ import ( "github.com/taciturnaxolotl/lard/internal/llm" "github.com/taciturnaxolotl/lard/internal/mcpserver" "github.com/taciturnaxolotl/lard/internal/store" + "github.com/taciturnaxolotl/lard/internal/tenant" ) func main() { @@ -57,15 +58,20 @@ func run() error { if cfg.MemoryDir == "" { cfg.MemoryDir = defaultMemDir() } - - if err := os.MkdirAll(filepath.Dir(cfg.DB), 0o700); err != nil { - return err + if cfg.DataDir == "" { + cfg.DataDir = defaultDataDir() } - st, err := store.Open(cfg.DB, cfg.MemoryDir) - if err != nil { - return fmt.Errorf("open store: %w", err) + + // Multi-user memory is keyed by the identity on the token, and only oauth + // mode produces one. Without oauth every request would land on + // primary_user, so a missing primary_user means nothing can be served at + // all: say so at boot rather than 401ing every request. + if cfg.MultiUser && auth.Mode(cfg.Auth.Mode) != auth.ModeOAuth { + if cfg.PrimaryUser == "" { + return fmt.Errorf("multi_user needs auth.mode = \"oauth\" (or a primary_user to attribute unidentified requests to)") + } + slog.Warn("multi_user without oauth: every caller shares one memory", "primary_user", cfg.PrimaryUser) } - defer st.Close() // The LLM client is optional at boot: the API works without it, only // /consolidate refuses. @@ -76,14 +82,57 @@ func run() error { llmClient = c } - api := httpapi.New(st, llmClient) + var api *httpapi.Server + if cfg.MultiUser { + layout := tenant.Layout{Root: cfg.DataDir} + if err := os.MkdirAll(layout.Root, 0o700); err != nil { + return err + } + // A server that was single-user until this boot has a database full of + // memory the owner would otherwise silently lose. Move it in. + if cfg.PrimaryUser != "" { + slug := tenant.Slug(cfg.PrimaryUser) + moved, err := tenant.AdoptLegacy(layout, slug, cfg.DB, cfg.MemoryDir) + if err != nil { + return fmt.Errorf("adopt single-user data: %w", err) + } + if moved { + slog.Info("adopted single-user data", "user", cfg.PrimaryUser, "dir", layout.Dir(slug)) + } + } + api = httpapi.NewMultiUser(httpapi.MultiUserConfig{ + Layout: layout, + PrimaryUser: cfg.PrimaryUser, + }, llmClient) + } else { + // Turning multi_user back off is the one way to lose sight of your + // memory: the tenant directories still hold it, but this path would + // open a brand new empty database and say nothing. Refuse instead, and + // name the directory the data is actually in. + if tenants := tenant.List(tenant.Layout{Root: cfg.DataDir}); len(tenants) > 0 { + if _, err := os.Stat(cfg.DB); err != nil { + return fmt.Errorf("multi_user is off but %d tenant(s) hold the memory under %s; "+ + "turn multi_user back on, or move %s/{lard.db,memory} to %s and %s", + len(tenants), cfg.DataDir, filepath.Join(cfg.DataDir, tenants[0]), cfg.DB, cfg.MemoryDir) + } + slog.Warn("multi_user is off; tenant memory under data_dir is not being served", "tenants", len(tenants), "data_dir", cfg.DataDir) + } + if err := os.MkdirAll(filepath.Dir(cfg.DB), 0o700); err != nil { + return err + } + st, err := store.Open(cfg.DB, cfg.MemoryDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + api = httpapi.New(st, llmClient) + } + defer api.Close() // Consolidate on its own once uploads go quiet, so a remote collector // feeding this server keeps memory current with nobody poking an endpoint. if after := cfg.Consolidate.ConsolidateAfter(); after > 0 { api.EnableAutoConsolidate(after, cfg.Consolidate.ConsolidateMaxWait()) defer api.StopAutoConsolidate() } - mcpSrv := mcpserver.New(api) authCfg := auth.Config{ Mode: auth.Mode(cfg.Auth.Mode), @@ -112,7 +161,7 @@ func run() error { } mux := http.NewServeMux() - mux.Handle("/mcp", mcpserver.HTTPHandler(mcpSrv)) + mux.Handle("/mcp", mcpserver.HTTPHandler(api)) // The trailing slash covers RFC 9728's path-suffixed documents (e.g. // /.well-known/oauth-protected-resource/mcp), which is what MCP clients // actually request. @@ -135,7 +184,11 @@ func run() error { _ = srv.Shutdown(shutdownCtx) }() - slog.Info("lard listening", "addr", cfg.Addr, "db", cfg.DB, "memory", cfg.MemoryDir, "auth", authCfg.Mode, "mcp", cfg.Addr+"/mcp") + if cfg.MultiUser { + slog.Info("lard listening", "addr", cfg.Addr, "data", cfg.DataDir, "users", "multi", "auth", authCfg.Mode, "mcp", cfg.Addr+"/mcp") + } else { + slog.Info("lard listening", "addr", cfg.Addr, "db", cfg.DB, "memory", cfg.MemoryDir, "auth", authCfg.Mode, "mcp", cfg.Addr+"/mcp") + } if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } @@ -155,3 +208,10 @@ func defaultMemDir() string { } return "memory" } + +func defaultDataDir() string { + if d, err := os.UserConfigDir(); err == nil { + return filepath.Join(d, "lard", "users") + } + return "users" +} diff --git a/config.example.toml b/config.example.toml index e3eeaf4..f7710d8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -7,6 +7,15 @@ addr = ":7477" db = "" # defaults to ~/.config/lard/lard.db memory_dir = "" # defaults to ~/.config/lard/memory +# Multi-user: every authenticated identity gets its own database and its own +# memory directory under data_dir. Nothing is shared between them. Needs +# auth.mode = "oauth", since that is what puts an identity on the request. +multi_user = false +data_dir = "" # defaults to ~/.config/lard/users +primary_user = "" # identity that owns requests carrying no OAuth identity. + # On the first multi-user boot, an existing single-user + # db + memory_dir are moved into this user's tenant. + # Consolidation model (any OpenAI-compatible endpoint; Hyper is the default) [llm] base_url = "" # defaults to https://hyper.charm.land diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9334b80..1800465 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -126,6 +126,12 @@ func IdentityFrom(ctx context.Context) (Identity, bool) { return id, ok } +// WithIdentity attaches an authenticated caller to a context, which is what +// the middleware does on a successful authorization. +func WithIdentity(ctx context.Context, id Identity) context.Context { + return context.WithValue(ctx, identityKey{}, id) +} + // Middleware enforces the configured mode on all requests except /healthz and // the OAuth discovery documents, which must stay reachable so an unauthorized // client can learn where to get a token. @@ -154,7 +160,7 @@ func Middleware(cfg Config, next http.Handler) http.Handler { writeChallenge(w, r, cfg, status, code, desc) return } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, id))) + next.ServeHTTP(w, r.WithContext(WithIdentity(r.Context(), id))) default: writeChallenge(w, r, cfg, http.StatusInternalServerError, "server_error", "unknown auth mode") } diff --git a/internal/config/config.go b/internal/config/config.go index d537c7c..25f08ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ package config import ( "os" "reflect" + "strconv" "strings" "time" @@ -13,9 +14,18 @@ import ( // Server holds all runtime configuration for the lard server. type Server struct { - Addr string `toml:"addr" env:"LARD_ADDR" default:":7477"` - DB string `toml:"db" env:"LARD_DB"` - MemoryDir string `toml:"memory_dir" env:"LARD_MEMORY_DIR"` + Addr string `toml:"addr" env:"LARD_ADDR" default:":7477"` + DB string `toml:"db" env:"LARD_DB"` + MemoryDir string `toml:"memory_dir" env:"LARD_MEMORY_DIR"` + + // MultiUser gives every authenticated identity its own isolated store + // under DataDir, instead of pooling everyone into one memory. + MultiUser bool `toml:"multi_user" env:"LARD_MULTI_USER"` + DataDir string `toml:"data_dir" env:"LARD_DATA_DIR"` + // PrimaryUser owns requests that carry no OAuth identity (token or none + // auth), and inherits a pre-existing single-user database on first boot. + PrimaryUser string `toml:"primary_user" env:"LARD_PRIMARY_USER"` + LLM LLM `toml:"llm"` Auth Auth `toml:"auth"` Collector Collector `toml:"collector"` @@ -157,6 +167,12 @@ func applyEnvToStruct(v reflect.Value) { switch fieldVal.Kind() { case reflect.String: fieldVal.SetString(val) + case reflect.Bool: + b, err := strconv.ParseBool(val) + if err != nil { + continue + } + fieldVal.SetBool(b) case reflect.Slice: if fieldVal.Type().Elem().Kind() == reflect.String { fieldVal.Set(reflect.ValueOf(splitList(val))) diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index a6d2943..b03edbf 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -2,6 +2,10 @@ // memory file operations (list/read/write/append/edit/delete), ingest, // consolidation, and the project registry. The MCP server wraps the same // store paths. +// +// Every handler works against a tenant resolved from the request's identity, +// so a single-user server and a multi-user one run the same code; see +// tenant.go. package httpapi import ( @@ -14,27 +18,10 @@ import ( "time" "github.com/taciturnaxolotl/lard/internal/auth" - "github.com/taciturnaxolotl/lard/internal/llm" "github.com/taciturnaxolotl/lard/internal/pipeline" - "github.com/taciturnaxolotl/lard/internal/store" "github.com/taciturnaxolotl/lard/internal/types" ) -// Server wires the store, registry, and consolidator to HTTP handlers. -type Server struct { - st *store.Store - registry *pipeline.Registry - llm *llm.Client - mux *http.ServeMux - auto *autoConsolidator - - // Single-flight consolidation. A manual /consolidate and the quiet - // timer both funnel through one job, so concurrent callers never start - // competing passes, and a caller going away doesn't kill the pass. - consolMu sync.Mutex - consolJob *consolidationJob -} - // consolidationJob is one in-flight consolidation pass. type consolidationJob struct { done chan struct{} // closed when the pass finishes @@ -81,42 +68,7 @@ func (j *consolidationJob) unsubscribe(ch chan pipeline.ProgressEvent) { } } -// New builds the HTTP server. llmClient may be nil if consolidation is never -// triggered from this process. -func New(st *store.Store, llmClient *llm.Client) *Server { - s := &Server{st: st, registry: pipeline.NewRegistry(st), llm: llmClient, mux: http.NewServeMux()} - s.routes() - return s -} - -// EnableAutoConsolidate makes the server consolidate on its own once uploads -// go quiet, so memory stays current without anyone calling /consolidate. No-op -// without an LLM client, since a pass would fail anyway. -func (s *Server) EnableAutoConsolidate(after, maxWait time.Duration) { - if s.llm == nil { - slog.Warn("auto-consolidate disabled: no LLM client") - return - } - s.auto = newAutoConsolidator(after, maxWait, func(ctx context.Context) error { - _, err := s.consolidate(ctx) - return err - }) - slog.Info("auto-consolidate enabled", "quiet_period", after, "max_wait", maxWait) -} - -// StopAutoConsolidate cancels any pending scheduled pass. -func (s *Server) StopAutoConsolidate() { - if s.auto != nil { - s.auto.Stop() - } -} - -func (s *Server) Handler() http.Handler { return s.mux } -func (s *Server) Registry() *pipeline.Registry { return s.registry } -func (s *Server) Store() *store.Store { return s.st } -func (s *Server) Consolidator() *pipeline.Consolidator { - return pipeline.New(s.st, s.llm, s.registry.Resolve) -} +func (s *Server) Handler() http.Handler { return s.mux } func (s *Server) routes() { s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) }) @@ -149,10 +101,14 @@ func (s *Server) routes() { // --- context --- func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) { + t := s.tenantOr(w, r) + if t == nil { + return + } projectID := r.URL.Query().Get("project") if projectID == "" { if hints := hintsFromQuery(r); hints != nil { - pid, err := s.registry.Resolve(hints) + pid, err := t.registry.Resolve(hints) if err != nil { writeErr(w, 500, err) return @@ -160,7 +116,7 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) { projectID = pid } } - bundle, err := s.ContextBundle(projectID) + bundle, err := t.ContextBundle(projectID) if err != nil { writeErr(w, 500, err) return @@ -168,42 +124,14 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, bundle) } -// ContextBundle assembles the injection bundle: profile in full, the subject -// listing, and (for a project session) that project's area file. -func (s *Server) ContextBundle(projectID string) (*types.ContextBundle, error) { - bundle := &types.ContextBundle{ProjectID: projectID} - if profile, err := s.st.GetSubject(types.KindProfile, "profile"); err != nil { - return nil, err - } else if profile != nil { - bundle.Profile = profile.Body - } - listing, err := s.st.ListSubjects() - if err != nil { - return nil, err - } - bundle.Listing = listing - if projectID != "" { - name, err := s.st.SubjectForProject(projectID) - if err != nil { - return nil, err - } - if name != "" { - sub, err := s.st.GetSubject(types.KindArea, name) - if err != nil { - return nil, err - } - if sub != nil { - bundle.Area = sub.Body - } - } - } - return bundle, nil -} - // --- subject memory files --- func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { - listing, err := s.st.ListSubjects() + t := s.tenantOr(w, r) + if t == nil { + return + } + listing, err := t.st.ListSubjects() if err != nil { writeErr(w, 500, err) return @@ -212,12 +140,16 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) { + t := s.tenantOr(w, r) + if t == nil { + return + } kind, name, err := types.ParseSubjectPath(r.PathValue("path")) if err != nil { writeErr(w, 400, err) return } - sub, err := s.st.GetSubject(kind, name) + sub, err := t.st.GetSubject(kind, name) if err != nil { writeErr(w, 500, err) return @@ -236,6 +168,10 @@ func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleWrite(w http.ResponseWriter, r *http.Request) { + t := s.tenantOr(w, r) + if t == nil { + return + } kind, name, err := types.ParseSubjectPath(r.PathValue("path")) if err != nil { writeErr(w, 400, err) @@ -252,7 +188,7 @@ func (s *Server) handleWrite(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err) return } - sub, err := pipeline.ApplyPatch(s.st, s.registry, kind, name, pipeline.SubjectPatch{ + sub, err := pipeline.ApplyPatch(t.st, t.registry, kind, name, pipeline.SubjectPatch{ Body: &body.Body, Description: body.Description, Aliases: body.Aliases, @@ -271,6 +207,10 @@ func (s *Server) handleWrite(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleAppend(w http.ResponseWriter, r *http.Request) { + t := s.tenantOr(w, r) + if t == nil { + return + } kind, name, err := types.ParseSubjectPath(r.PathValue("path")) if err != nil { writeErr(w, 400, err) @@ -283,7 +223,7 @@ func (s *Server) handleAppend(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err) return } - sub, err := pipeline.AppendLine(s.st, kind, name, body.Line) + sub, err := pipeline.AppendLine(t.st, kind, name, body.Line) if err != nil { writeErr(w, 400, err) return @@ -292,12 +232,16 @@ func (s *Server) handleAppend(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { + t := s.tenantOr(w, r) + if t == nil { + return + } kind, name, err := types.ParseSubjectPath(r.PathValue("path")) if err != nil { writeErr(w, 400, err) return } - if err := s.st.DeleteSubject(kind, name); err != nil { + if err := t.st.DeleteSubject(kind, name); err != nil { writeErr(w, 500, err) return } @@ -316,7 +260,8 @@ func writeConflict(w http.ResponseWriter, current *types.Subject) { // --- ingest & consolidate --- // handleWhoami reports the authenticated caller. Reaching this handler at all -// means the credentials are good; the body says which identity they mapped to. +// means the credentials are good; the body says which identity they mapped to, +// and which memory that identity opens. func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) { out := map[string]any{"authenticated": true} if id, ok := auth.IdentityFrom(r.Context()); ok { @@ -324,40 +269,51 @@ func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) { out["clientId"] = id.ClientID out["scopes"] = id.Scopes } + if t, err := s.TenantFor(r.Context()); err == nil && t.key != "" { + out["tenant"] = t.key + } writeJSON(w, 200, out) } func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { + t := s.tenantOr(w, r) + if t == nil { + return + } var req types.IngestRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<20)).Decode(&req); err != nil { writeErr(w, 400, err) return } - n, err := s.st.IngestSessions(req.Collector, req.Sessions) + n, err := t.st.IngestSessions(req.Collector, req.Sessions) if err != nil { writeErr(w, 500, err) return } for _, sess := range req.Sessions { if sess.ProjectHints != nil { - if _, err := s.registry.Resolve(sess.ProjectHints); err != nil { + if _, err := t.registry.Resolve(sess.ProjectHints); err != nil { slog.Warn("ingest: resolve project", "session", sess.SessionID, "error", err) } } } // New work landed: start (or extend) the quiet period before consolidating. - if n > 0 && s.auto != nil { - s.auto.Trigger() + if n > 0 && t.auto != nil { + t.auto.Trigger() } writeJSON(w, 200, map[string]int{"ingested": n}) } func (s *Server) handleConsolidate(w http.ResponseWriter, r *http.Request) { - if s.llm == nil { + t := s.tenantOr(w, r) + if t == nil { + return + } + if t.llm == nil { writeErr(w, 503, errors.New("consolidation unavailable: no LLM client configured")) return } - job := s.startConsolidation() + job := t.startConsolidation() events := job.subscribe() defer job.unsubscribe(events) @@ -419,8 +375,8 @@ func (s *Server) handleConsolidate(w http.ResponseWriter, r *http.Request) { // consolidate waits for one consolidation pass and returns its result. // Used by the quiet-timer auto-pass, which wants the outcome but not the // step-by-step stream. -func (s *Server) consolidate(ctx context.Context) (pipeline.Result, error) { - job := s.startConsolidation() +func (t *Tenant) consolidate(ctx context.Context) (pipeline.Result, error) { + job := t.startConsolidation() select { case <-job.done: return job.res, job.err @@ -429,31 +385,32 @@ func (s *Server) consolidate(ctx context.Context) (pipeline.Result, error) { } } -// startConsolidation starts a consolidation pass, or joins the one already -// running. The pass is single-flight and detached from the caller: a manual -// call, a second caller mid-pass, and the quiet timer all share one job, and -// a client going away never kills work already in flight. Both phases are -// checkpointed, so even a server restart resumes where the pass stopped. -func (s *Server) startConsolidation() *consolidationJob { - s.consolMu.Lock() - defer s.consolMu.Unlock() - if s.consolJob != nil { - return s.consolJob +// startConsolidation starts a consolidation pass for this tenant, or joins the +// one already running. The pass is single-flight per tenant and detached from +// the caller: a manual call, a second caller mid-pass, and the quiet timer all +// share one job, and a client going away never kills work already in flight. +// Both phases are checkpointed, so even a server restart resumes where the +// pass stopped. +func (t *Tenant) startConsolidation() *consolidationJob { + t.consolMu.Lock() + defer t.consolMu.Unlock() + if t.consolJob != nil { + return t.consolJob } job := &consolidationJob{done: make(chan struct{})} - s.consolJob = job + t.consolJob = job go func() { runCtx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) defer cancel() - job.res, job.err = s.Consolidator().Run(runCtx, 0, job.publish) + job.res, job.err = t.Consolidator().Run(runCtx, 0, job.publish) close(job.done) - s.consolMu.Lock() - s.consolJob = nil - s.consolMu.Unlock() + t.consolMu.Lock() + t.consolJob = nil + t.consolMu.Unlock() if job.err != nil { - slog.Error("consolidate", "error", job.err) + slog.Error("consolidate", "tenant", t.key, "error", job.err) } else { - slog.Info("consolidate done", "extracted", job.res.Extracted, "synthesized", job.res.Synthesized) + slog.Info("consolidate done", "tenant", t.key, "extracted", job.res.Extracted, "synthesized", job.res.Synthesized) } }() return job @@ -462,6 +419,10 @@ func (s *Server) startConsolidation() *consolidationJob { // --- projects --- func (s *Server) handleProjectResolve(w http.ResponseWriter, r *http.Request) { + t := s.tenantOr(w, r) + if t == nil { + return + } var body struct { Hints *types.ProjectHints `json:"hints"` } @@ -469,7 +430,7 @@ func (s *Server) handleProjectResolve(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err) return } - id, err := s.registry.Resolve(body.Hints) + id, err := t.registry.Resolve(body.Hints) if err != nil { writeErr(w, 500, err) return @@ -478,7 +439,11 @@ func (s *Server) handleProjectResolve(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { - projects, err := s.st.ListProjects() + t := s.tenantOr(w, r) + if t == nil { + return + } + projects, err := t.st.ListProjects() if err != nil { writeErr(w, 500, err) return @@ -487,6 +452,10 @@ func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleProjectAlias(w http.ResponseWriter, r *http.Request) { + t := s.tenantOr(w, r) + if t == nil { + return + } id := r.PathValue("id") var body struct { Kind string `json:"kind"` @@ -499,7 +468,7 @@ func (s *Server) handleProjectAlias(w http.ResponseWriter, r *http.Request) { if body.Kind == "remote" { body.Value = pipeline.NormalizeRemote(body.Value) } - if err := s.st.AddAlias(id, body.Kind, body.Value); err != nil { + if err := t.st.AddAlias(id, body.Kind, body.Value); err != nil { writeErr(w, 500, err) return } diff --git a/internal/httpapi/tenant.go b/internal/httpapi/tenant.go new file mode 100644 index 0000000..4506f37 --- /dev/null +++ b/internal/httpapi/tenant.go @@ -0,0 +1,273 @@ +package httpapi + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/taciturnaxolotl/lard/internal/auth" + "github.com/taciturnaxolotl/lard/internal/llm" + "github.com/taciturnaxolotl/lard/internal/pipeline" + "github.com/taciturnaxolotl/lard/internal/store" + "github.com/taciturnaxolotl/lard/internal/tenant" + "github.com/taciturnaxolotl/lard/internal/types" +) + +// ErrNoIdentity means a request reached a multi-user server without an +// authenticated identity to attribute it to. There is no sensible default +// memory to serve, so the request is refused rather than guessed at. +var ErrNoIdentity = errors.New("no authenticated identity; multi-user lard needs oauth (or a configured primary user)") + +// Tenant is one user's memory: their store, their project registry, and their +// consolidation state. Everything that used to hang off the server hangs off a +// tenant instead, which is what makes the single-user and multi-user cases the +// same code. +type Tenant struct { + key string + subject string + st *store.Store + registry *pipeline.Registry + llm *llm.Client + auto *autoConsolidator + + // Single-flight consolidation. A manual /consolidate and the quiet + // timer both funnel through one job, so concurrent callers never start + // competing passes, and a caller going away doesn't kill the pass. + consolMu sync.Mutex + consolJob *consolidationJob +} + +func newTenant(key, subject string, st *store.Store, llmClient *llm.Client) *Tenant { + return &Tenant{key: key, subject: subject, st: st, registry: pipeline.NewRegistry(st), llm: llmClient} +} + +// Key is the tenant's storage slug, or "" for a single-user server. +func (t *Tenant) Key() string { return t.key } + +// Subject is the identity that owns this tenant. +func (t *Tenant) Subject() string { return t.subject } + +// Store is the tenant's persistence layer. +func (t *Tenant) Store() *store.Store { return t.st } + +// Registry resolves project hints within this tenant. +func (t *Tenant) Registry() *pipeline.Registry { return t.registry } + +// Consolidator builds a pass over this tenant's store. +func (t *Tenant) Consolidator() *pipeline.Consolidator { + return pipeline.New(t.st, t.llm, t.registry.Resolve) +} + +// ContextBundle assembles the injection bundle: profile in full, the subject +// listing, and (for a project session) that project's area file. +func (t *Tenant) ContextBundle(projectID string) (*types.ContextBundle, error) { + bundle := &types.ContextBundle{ProjectID: projectID} + if profile, err := t.st.GetSubject(types.KindProfile, "profile"); err != nil { + return nil, err + } else if profile != nil { + bundle.Profile = profile.Body + } + listing, err := t.st.ListSubjects() + if err != nil { + return nil, err + } + bundle.Listing = listing + if projectID != "" { + name, err := t.st.SubjectForProject(projectID) + if err != nil { + return nil, err + } + if name != "" { + sub, err := t.st.GetSubject(types.KindArea, name) + if err != nil { + return nil, err + } + if sub != nil { + bundle.Area = sub.Body + } + } + } + return bundle, nil +} + +// enableAuto gives this tenant its own quiet timer. Timers are per tenant +// because ingests are: one user uploading an afternoon of work should not +// reset anyone else's clock or drag their memory through a pass. +func (t *Tenant) enableAuto(after, maxWait time.Duration) { + if t.llm == nil { + return + } + t.auto = newAutoConsolidator(after, maxWait, func(ctx context.Context) error { + _, err := t.consolidate(ctx) + return err + }) +} + +func (t *Tenant) stop() { + if t.auto != nil { + t.auto.Stop() + } +} + +// MultiUserConfig configures a server that keeps one store per identity. +type MultiUserConfig struct { + // Layout is where tenant directories live. + Layout tenant.Layout + // PrimaryUser owns requests that arrive with no OAuth identity, which is + // every request under token or none auth. Empty means such requests are + // refused. + PrimaryUser string +} + +// Server wires tenants to HTTP handlers. In single-user mode one tenant is +// fixed at construction; in multi-user mode tenants are opened on first +// contact and keyed by the caller's identity. +type Server struct { + llm *llm.Client + mux *http.ServeMux + + fixed *Tenant // single-user mode + + multi bool + layout tenant.Layout + primary string + + autoOn bool + autoAfter time.Duration + autoMaxWait time.Duration + + mu sync.Mutex + open map[string]*Tenant +} + +// New builds a single-user server over one already-open store. llmClient may +// be nil if consolidation is never triggered from this process. +func New(st *store.Store, llmClient *llm.Client) *Server { + s := &Server{llm: llmClient, mux: http.NewServeMux()} + s.fixed = newTenant("", "", st, llmClient) + s.routes() + return s +} + +// NewMultiUser builds a server that gives every authenticated identity its own +// isolated store, opened on first contact. +func NewMultiUser(cfg MultiUserConfig, llmClient *llm.Client) *Server { + s := &Server{ + llm: llmClient, + mux: http.NewServeMux(), + multi: true, + layout: cfg.Layout, + primary: cfg.PrimaryUser, + open: map[string]*Tenant{}, + } + s.routes() + return s +} + +// TenantFor resolves the caller's tenant, opening its store the first time +// that identity is seen. +func (s *Server) TenantFor(ctx context.Context) (*Tenant, error) { + if !s.multi { + return s.fixed, nil + } + subject := s.primary + if id, ok := auth.IdentityFrom(ctx); ok && id.Subject != "" { + subject = id.Subject + } + if subject == "" { + return nil, ErrNoIdentity + } + key := tenant.Slug(subject) + + s.mu.Lock() + defer s.mu.Unlock() + if t, ok := s.open[key]; ok { + return t, nil + } + // Tenants stay open for the life of the process. A homelab serves a + // handful of people, one SQLite connection each, and an eviction policy + // that can close a store out from under a running consolidation pass buys + // nothing worth that risk. + fresh := !s.layout.Exists(key) + st, err := s.layout.Open(key) + if err != nil { + return nil, err + } + t := newTenant(key, subject, st, s.llm) + if s.autoOn { + t.enableAuto(s.autoAfter, s.autoMaxWait) + } + s.open[key] = t + if fresh { + slog.Info("tenant created", "subject", subject, "dir", s.layout.Dir(key)) + } else { + slog.Info("tenant opened", "subject", subject, "dir", s.layout.Dir(key)) + } + return t, nil +} + +// tenantOr resolves the caller's tenant, writing the error response itself +// when it cannot. A nil return means the response is already written. +func (s *Server) tenantOr(w http.ResponseWriter, r *http.Request) *Tenant { + t, err := s.TenantFor(r.Context()) + if err != nil { + if errors.Is(err, ErrNoIdentity) { + writeErr(w, http.StatusUnauthorized, err) + } else { + writeErr(w, http.StatusInternalServerError, fmt.Errorf("open tenant: %w", err)) + } + return nil + } + return t +} + +// EnableAutoConsolidate makes tenants consolidate on their own once uploads go +// quiet, so memory stays current without anyone calling /consolidate. No-op +// without an LLM client, since a pass would fail anyway. +func (s *Server) EnableAutoConsolidate(after, maxWait time.Duration) { + if s.llm == nil { + slog.Warn("auto-consolidate disabled: no LLM client") + return + } + s.autoOn, s.autoAfter, s.autoMaxWait = true, after, maxWait + if s.fixed != nil { + s.fixed.enableAuto(after, maxWait) + } + slog.Info("auto-consolidate enabled", "quiet_period", after, "max_wait", maxWait) +} + +// StopAutoConsolidate cancels every pending scheduled pass. +func (s *Server) StopAutoConsolidate() { + s.eachTenant((*Tenant).stop) +} + +// Close stops timers and closes every open store. +func (s *Server) Close() error { + var err error + s.eachTenant(func(t *Tenant) { + t.stop() + if cerr := t.st.Close(); cerr != nil && err == nil { + err = cerr + } + }) + return err +} + +func (s *Server) eachTenant(fn func(*Tenant)) { + if s.fixed != nil { + fn(s.fixed) + } + s.mu.Lock() + open := make([]*Tenant, 0, len(s.open)) + for _, t := range s.open { + open = append(open, t) + } + s.mu.Unlock() + for _, t := range open { + fn(t) + } +} diff --git a/internal/httpapi/tenant_test.go b/internal/httpapi/tenant_test.go new file mode 100644 index 0000000..f6aa70f --- /dev/null +++ b/internal/httpapi/tenant_test.go @@ -0,0 +1,129 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/taciturnaxolotl/lard/internal/auth" + "github.com/taciturnaxolotl/lard/internal/store" + "github.com/taciturnaxolotl/lard/internal/tenant" + "github.com/taciturnaxolotl/lard/internal/types" +) + +func multiUserServer(t *testing.T) *Server { + t.Helper() + s := NewMultiUser(MultiUserConfig{Layout: tenant.Layout{Root: t.TempDir()}}, nil) + t.Cleanup(func() { s.Close() }) + return s +} + +// as builds a request carrying an authenticated identity, the way the auth +// middleware hands it to the handlers. +func as(subject, method, target string, body string) *http.Request { + r := httptest.NewRequest(method, target, strings.NewReader(body)) + if subject != "" { + r = r.WithContext(auth.WithIdentity(r.Context(), auth.Identity{Subject: subject})) + } + return r +} + +func do(s *Server, r *http.Request) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + return w +} + +func TestTenantsDoNotSeeEachOther(t *testing.T) { + s := multiUserServer(t) + + w := do(s, as("https://alice.example", "POST", "/memory/topics/coffee", `{"line":"alice drinks decaf"}`)) + if w.Code != 200 { + t.Fatalf("alice write: %d %s", w.Code, w.Body) + } + + // Bob asks for the same path. Alice's line must not be there. + w = do(s, as("https://bob.example", "GET", "/memory/topics/coffee", "")) + if w.Code != 404 { + t.Fatalf("bob read: want 404, got %d %s", w.Code, w.Body) + } + + // And Bob's listing must be empty rather than showing alice's subject. + w = do(s, as("https://bob.example", "GET", "/memory", "")) + var listing []types.SubjectListing + if err := json.Unmarshal(w.Body.Bytes(), &listing); err != nil { + t.Fatalf("decode listing: %v (%s)", err, w.Body) + } + if len(listing) != 0 { + t.Fatalf("bob sees alice's memory: %+v", listing) + } + + // Alice still has hers. + w = do(s, as("https://alice.example", "GET", "/memory/topics/coffee", "")) + if w.Code != 200 || !strings.Contains(w.Body.String(), "decaf") { + t.Fatalf("alice read back: %d %s", w.Code, w.Body) + } +} + +func TestTenantPersistsAcrossRequests(t *testing.T) { + s := multiUserServer(t) + first, err := s.TenantFor(as("https://alice.example", "GET", "/", "").Context()) + if err != nil { + t.Fatal(err) + } + second, err := s.TenantFor(as("https://alice.example", "GET", "/", "").Context()) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("same identity opened two stores") + } +} + +func TestUnidentifiedRequestIsRefused(t *testing.T) { + s := multiUserServer(t) + w := do(s, as("", "GET", "/memory", "")) + if w.Code != http.StatusUnauthorized { + t.Fatalf("want 401 for an unidentified caller, got %d %s", w.Code, w.Body) + } +} + +func TestPrimaryUserOwnsUnidentifiedRequests(t *testing.T) { + s := NewMultiUser(MultiUserConfig{ + Layout: tenant.Layout{Root: t.TempDir()}, + PrimaryUser: "https://krn.example", + }, nil) + t.Cleanup(func() { s.Close() }) + + if w := do(s, as("", "POST", "/memory/topics/x", `{"line":"from a token client"}`)); w.Code != 200 { + t.Fatalf("unidentified write: %d %s", w.Code, w.Body) + } + w := do(s, as("https://krn.example", "GET", "/memory/topics/x", "")) + if w.Code != 200 || !strings.Contains(w.Body.String(), "token client") { + t.Fatalf("primary user should own that write: %d %s", w.Code, w.Body) + } +} + +func TestSingleUserServerIgnoresIdentity(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "lard.db"), filepath.Join(dir, "memory")) + if err != nil { + t.Fatal(err) + } + s := New(st, nil) + t.Cleanup(func() { s.Close() }) + + if w := do(s, as("https://alice.example", "POST", "/memory/topics/x", `{"line":"one memory"}`)); w.Code != 200 { + t.Fatalf("write: %d %s", w.Code, w.Body) + } + // No tenants in single-user mode: everyone lands on the same store. + for _, subject := range []string{"https://bob.example", ""} { + w := do(s, as(subject, "GET", "/memory/topics/x", "")) + if w.Code != 200 || !strings.Contains(w.Body.String(), "one memory") { + t.Fatalf("subject %q: %d %s", subject, w.Code, w.Body) + } + } +} diff --git a/internal/mcpserver/mcpserver.go b/internal/mcpserver/mcpserver.go index b849d9d..299fe63 100644 --- a/internal/mcpserver/mcpserver.go +++ b/internal/mcpserver/mcpserver.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" "strings" + "sync" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -34,8 +35,10 @@ Write: - Skip anything true only for the current task, and anything the repo already shows. - memory_write is for full rewrites; memory_read first for the version token.` -// New builds the MCP server backed by the HTTP API server's store. -func New(api *httpapi.Server) *mcp.Server { +// New builds an MCP server bound to one tenant's memory. Tool handlers do not +// see the HTTP request, so the tenant is resolved before the server is built +// rather than inside each tool; HTTPHandler does that per request. +func New(mem *httpapi.Tenant) *mcp.Server { s := mcp.NewServer( &mcp.Implementation{Name: "lard", Version: "0.2.0"}, &mcp.ServerOptions{Instructions: instructions}, @@ -56,14 +59,14 @@ func New(api *httpapi.Server) *mcp.Server { if projectID == "" { hints := &types.ProjectHints{GitRemote: args.GitRemote, Path: args.Path, Name: args.Name} if *hints != (types.ProjectHints{}) { - pid, err := api.Registry().Resolve(hints) + pid, err := mem.Registry().Resolve(hints) if err != nil { return errResult(err), nil, nil } projectID = pid } } - bundle, err := api.ContextBundle(projectID) + bundle, err := mem.ContextBundle(projectID) if err != nil { return errResult(err), nil, nil } @@ -76,7 +79,7 @@ func New(api *httpapi.Server) *mcp.Server { Name: "memory_list", Description: `List all memory subjects: path, kind, description, aliases. The retrieval surface — decide what to read from descriptions alone.`, }, func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { - listing, err := api.Store().ListSubjects() + listing, err := mem.Store().ListSubjects() if err != nil { return errResult(err), nil, nil } @@ -96,7 +99,7 @@ func New(api *httpapi.Server) *mcp.Server { if err != nil { return errResult(err), nil, nil } - sub, err := api.Store().GetSubject(kind, name) + sub, err := mem.Store().GetSubject(kind, name) if err != nil { return errResult(err), nil, nil } @@ -123,7 +126,7 @@ func New(api *httpapi.Server) *mcp.Server { if err != nil { return errResult(err), nil, nil } - sub, err := pipeline.ApplyPatch(api.Store(), api.Registry(), kind, name, pipeline.SubjectPatch{ + sub, err := pipeline.ApplyPatch(mem.Store(), mem.Registry(), kind, name, pipeline.SubjectPatch{ Body: &args.Body, Description: args.Description, Aliases: args.Aliases, @@ -156,7 +159,7 @@ func New(api *httpapi.Server) *mcp.Server { if err != nil { return errResult(err), nil, nil } - sub, err := pipeline.AppendLine(api.Store(), kind, name, args.Line) + sub, err := pipeline.AppendLine(mem.Store(), kind, name, args.Line) if err != nil { return errResult(err), nil, nil } @@ -175,7 +178,7 @@ func New(api *httpapi.Server) *mcp.Server { if err != nil { return errResult(err), nil, nil } - if err := api.Store().DeleteSubject(kind, name); err != nil { + if err := mem.Store().DeleteSubject(kind, name); err != nil { return errResult(err), nil, nil } return textResult("deleted " + args.Path), nil, nil @@ -192,7 +195,14 @@ func errResult(err error) *mcp.CallToolResult { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}} } -// HTTPHandler serves the MCP server over streamable HTTP. +// HTTPHandler serves MCP over streamable HTTP, one MCP server per tenant. +// +// Tool handlers run on the session's goroutine and never see the HTTP request, +// so the caller's identity cannot be read inside a tool. Instead the tenant is +// resolved here, per request, and the MCP server bound to it is reused for +// every later call from that identity. Sessions therefore belong to a tenant, +// which is exactly the isolation we want: a session id minted for one user is +// unknown to another's server. // // The SDK's DNS-rebinding guard rejects any request arriving on a loopback // socket that carries a non-loopback Host header. Behind a reverse proxy @@ -200,9 +210,44 @@ func errResult(err error) *mcp.CallToolResult { // What it defends against — a browser tricked into reaching an // unauthenticated local server — does not apply: lard authenticates every // request and is reached through the proxy rather than directly. -func HTTPHandler(s *mcp.Server) *mcp.StreamableHTTPHandler { - return mcp.NewStreamableHTTPHandler( - func(*http.Request) *mcp.Server { return s }, +func HTTPHandler(api *httpapi.Server) http.Handler { + var ( + mu sync.Mutex + servers = map[string]*mcp.Server{} + ) + forTenant := func(t *httpapi.Tenant) *mcp.Server { + mu.Lock() + defer mu.Unlock() + if s, ok := servers[t.Key()]; ok { + return s + } + s := New(t) + servers[t.Key()] = s + return s + } + + inner := mcp.NewStreamableHTTPHandler( + func(r *http.Request) *mcp.Server { + t, err := api.TenantFor(r.Context()) + if err != nil { + return nil + } + return forTenant(t) + }, &mcp.StreamableHTTPOptions{DisableLocalhostProtection: true}, ) + + // Resolve once up front so an unidentified caller gets a 401 (which tells + // an MCP client to go get a token) instead of the SDK's bare 400. + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := api.TenantFor(r.Context()); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, httpapi.ErrNoIdentity) { + status = http.StatusUnauthorized + } + http.Error(w, err.Error(), status) + return + } + inner.ServeHTTP(w, r) + }) } diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go new file mode 100644 index 0000000..c9e56cb --- /dev/null +++ b/internal/tenant/tenant.go @@ -0,0 +1,138 @@ +// Package tenant lays out per-user storage. A tenant is a whole store: its +// own SQLite database and its own directory of markdown subject files. Nothing +// is shared, so no query in the store layer needs to know users exist. +// +// The alternative — a user_id column on every table — would still need a +// directory per user for the subject files, and would put the burden of +// remembering the filter on every future query. Isolation by directory is the +// version that cannot be forgotten. +package tenant + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/taciturnaxolotl/lard/internal/store" +) + +// Layout maps tenant keys to paths under a single data root. +type Layout struct{ Root string } + +// Slug turns an identity (an authorization server "me" URL) into a directory +// name: a readable prefix for whoever is reading `ls`, plus a hash so that two +// identities never collide and nothing from the URL can escape the root. +func Slug(subject string) string { + sum := sha256.Sum256([]byte(subject)) + digest := hex.EncodeToString(sum[:])[:12] + + var b strings.Builder + last := byte('-') + for i := 0; i < len(subject) && b.Len() < 32; i++ { + c := subject[i] + switch { + case c >= 'A' && c <= 'Z': + c += 'a' - 'A' + fallthrough + case c >= 'a' && c <= 'z', c >= '0' && c <= '9': + b.WriteByte(c) + last = c + default: + if last != '-' { + b.WriteByte('-') + last = '-' + } + } + } + readable := strings.Trim(b.String(), "-") + // Scheme prefixes are noise on every identity from the same provider. + for _, p := range []string{"https-", "http-"} { + readable = strings.TrimPrefix(readable, p) + } + if readable == "" { + readable = "user" + } + return readable + "-" + digest +} + +// Dir is a tenant's directory. +func (l Layout) Dir(slug string) string { return filepath.Join(l.Root, slug) } + +// DBPath is a tenant's SQLite database. +func (l Layout) DBPath(slug string) string { return filepath.Join(l.Dir(slug), "lard.db") } + +// MemDir is a tenant's subject-file root. +func (l Layout) MemDir(slug string) string { return filepath.Join(l.Dir(slug), "memory") } + +// Open opens a tenant's store, creating its directory on first contact. +func (l Layout) Open(slug string) (*store.Store, error) { + if err := os.MkdirAll(l.Dir(slug), 0o700); err != nil { + return nil, fmt.Errorf("create tenant dir: %w", err) + } + st, err := store.Open(l.DBPath(slug), l.MemDir(slug)) + if err != nil { + return nil, fmt.Errorf("open tenant %s: %w", slug, err) + } + return st, nil +} + +// Exists reports whether a tenant's directory has been created. +func (l Layout) Exists(slug string) bool { + _, err := os.Stat(l.Dir(slug)) + return err == nil +} + +// List names the tenants that already have a directory under the root. +func List(l Layout) []string { + entries, err := os.ReadDir(l.Root) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(l.DBPath(e.Name())); err == nil { + out = append(out, e.Name()) + } + } + return out +} + +// AdoptLegacy moves a single-user database and memory directory into a +// tenant's directory, so turning on multi-user does not strand the memory +// accumulated before the switch. It reports whether it moved anything, and +// does nothing if the tenant directory already exists. +func AdoptLegacy(l Layout, slug, legacyDB, legacyMem string) (bool, error) { + if slug == "" || l.Exists(slug) { + return false, nil + } + if _, err := os.Stat(legacyDB); err != nil { + return false, nil + } + if err := os.MkdirAll(l.Dir(slug), 0o700); err != nil { + return false, err + } + // The WAL and shared-memory sidecars belong to the database. Moving the + // database alone would leave committed-but-uncheckpointed transactions + // behind, which is data loss dressed up as a successful migration. + for _, suffix := range []string{"", "-wal", "-shm"} { + src := legacyDB + suffix + if _, err := os.Stat(src); err != nil { + continue + } + if err := os.Rename(src, l.DBPath(slug)+suffix); err != nil { + return false, fmt.Errorf("move %s: %w", src, err) + } + } + if _, err := os.Stat(legacyMem); err == nil { + if err := os.Rename(legacyMem, l.MemDir(slug)); err != nil { + return false, fmt.Errorf("move %s: %w", legacyMem, err) + } + } + return true, nil +} diff --git a/internal/tenant/tenant_test.go b/internal/tenant/tenant_test.go new file mode 100644 index 0000000..82255ff --- /dev/null +++ b/internal/tenant/tenant_test.go @@ -0,0 +1,78 @@ +package tenant + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSlugIsReadableAndStable(t *testing.T) { + got := Slug("https://dunkirk.sh/") + if !strings.HasPrefix(got, "dunkirk-sh-") { + t.Fatalf("want a readable prefix, got %q", got) + } + if got != Slug("https://dunkirk.sh/") { + t.Fatal("slug is not stable across calls") + } +} + +func TestSlugSeparatesDistinctIdentities(t *testing.T) { + // Same readable prefix after filtering, different identities: the hash is + // the only thing keeping these two people's memory apart. + a := Slug("https://example.com/a") + b := Slug("https://example.com/b") + if a == b { + t.Fatalf("distinct identities collided: %q", a) + } +} + +func TestSlugCannotEscapeTheRoot(t *testing.T) { + for _, subject := range []string{"../../etc/passwd", "https://x/../../y", "/", ""} { + s := Slug(subject) + if s != filepath.Base(s) || strings.Contains(s, "..") { + t.Fatalf("subject %q produced traversable slug %q", subject, s) + } + } +} + +func TestAdoptLegacyMovesDatabaseAndSidecars(t *testing.T) { + dir := t.TempDir() + legacyDB := filepath.Join(dir, "lard.db") + legacyMem := filepath.Join(dir, "memory") + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.WriteFile(legacyDB+suffix, []byte(suffix), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(legacyMem, "areas"), 0o755); err != nil { + t.Fatal(err) + } + + l := Layout{Root: filepath.Join(dir, "users")} + moved, err := AdoptLegacy(l, "krn-abc", legacyDB, legacyMem) + if err != nil || !moved { + t.Fatalf("adopt: moved=%v err=%v", moved, err) + } + for _, suffix := range []string{"", "-wal", "-shm"} { + if _, err := os.Stat(l.DBPath("krn-abc") + suffix); err != nil { + t.Fatalf("sidecar %q not moved: %v", suffix, err) + } + } + if _, err := os.Stat(filepath.Join(l.MemDir("krn-abc"), "areas")); err != nil { + t.Fatalf("memory dir not moved: %v", err) + } + + // Idempotent: a second boot must not clobber the tenant it just made. + if moved, err := AdoptLegacy(l, "krn-abc", legacyDB, legacyMem); err != nil || moved { + t.Fatalf("second adopt: moved=%v err=%v", moved, err) + } +} + +func TestAdoptLegacyWithNothingToAdopt(t *testing.T) { + l := Layout{Root: t.TempDir()} + moved, err := AdoptLegacy(l, "krn-abc", filepath.Join(t.TempDir(), "absent.db"), "") + if err != nil || moved { + t.Fatalf("moved=%v err=%v", moved, err) + } +}