matrixcloud / internal /store /store.go
github-actions[bot]
Deploy from 4dfd54f2
857a91b
Raw
History Blame Contribute Delete
11.7 kB
// Package store is the SQLite-backed persistence layer for multitenant users,
// workspaces (tenants) and sessions. It uses the pure-Go modernc.org/sqlite
// driver so the binary stays statically linkable with CGO disabled.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"github.com/agent-matrix/matrix-runtime/internal/auth"
)
// Common errors.
var (
ErrEmailTaken = errors.New("an account with this email already exists")
ErrInvalidLogin = errors.New("invalid email or password")
ErrNotFound = errors.New("not found")
)
// SessionTTL is how long an issued session token remains valid.
const SessionTTL = 30 * 24 * time.Hour
// Store wraps a SQL database (SQLite by default, or PostgreSQL/Neon for the
// hosted control plane). On Postgres all objects live in a dedicated schema so
// MatrixCloud never collides with other apps sharing the instance.
type Store struct {
db *sql.DB
box *auth.SecretBox
pg bool
schema string
tblRe *regexp.Regexp
}
// pgTables are the tables MatrixCloud owns. On Postgres every reference to them
// is schema-qualified (qualify) so we never depend on search_path — which
// Neon's connection pooler does not preserve — and never collide with other
// apps that share the database (e.g. admin.matrixhub.io's own `users` table).
var pgTables = []string{
"model_runtime_installations", "runtime_join_tokens", "provider_credentials",
"email_verifications", "password_resets", "model_profiles", "usage_events",
"audit_events", "workspaces", "sessions", "runtimes", "users",
}
func compileTableRe() *regexp.Regexp {
return regexp.MustCompile(`\b(` + strings.Join(pgTables, "|") + `)\b`)
}
// qualify rewrites bare table names to "<schema>.<table>" for Postgres. Word
// boundaries keep it from touching columns/index names (e.g. workspace_id,
// idx_users_workspace). No-op for SQLite.
func (s *Store) qualify(q string) string {
if !s.pg || s.tblRe == nil {
return q
}
return s.tblRe.ReplaceAllString(q, s.schema+`.${1}`)
}
// rb rewrites "?" placeholders to "$N" for Postgres; no-op for SQLite.
func (s *Store) rb(q string) string {
if !s.pg {
return q
}
var b strings.Builder
n := 0
for i := 0; i < len(q); i++ {
if q[i] == '?' {
n++
b.WriteByte('$')
b.WriteString(strconv.Itoa(n))
} else {
b.WriteByte(q[i])
}
}
return b.String()
}
func (s *Store) exec(q string, a ...any) (sql.Result, error) {
return s.db.Exec(s.rb(s.qualify(q)), a...)
}
func (s *Store) query(q string, a ...any) (*sql.Rows, error) {
return s.db.Query(s.rb(s.qualify(q)), a...)
}
func (s *Store) queryRow(q string, a ...any) *sql.Row { return s.db.QueryRow(s.rb(s.qualify(q)), a...) }
// User is a row in the users table joined with its workspace.
type User struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
WorkspaceName string `json:"workspace"`
WorkspaceSlug string `json:"workspace_slug"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
// Workspace is a tenant.
type Workspace struct {
ID string
Name string
Slug string
CreatedAt time.Time
}
// Open opens (creating if needed) the SQLite database at path and applies the
// schema. A WAL journal and busy timeout keep concurrent access smooth.
func Open(path string) (*Store, error) {
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1) // serialise writes; modernc sqlite is happiest single-writer
box, err := auth.LoadSecretBox(filepath.Dir(path))
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("secret key: %w", err)
}
s := &Store{db: db, box: box}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
// OpenPostgres opens a PostgreSQL/Neon database (e.g. for cloud.matrixhub.io).
// All MatrixCloud objects are created in and resolved from `schema` (default
// "matrixcloud") so the instance can be shared with other apps without
// collisions. secretDir holds the at-rest encryption key.
func OpenPostgres(dsn, schema, secretDir string) (*Store, error) {
if schema == "" {
schema = "matrixcloud"
}
// Pin the search_path so unqualified table names resolve into our schema.
if !strings.Contains(dsn, "search_path=") {
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn += sep + "search_path=" + schema
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
box, err := auth.LoadSecretBox(secretDir)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("secret key: %w", err)
}
s := &Store{db: db, box: box, pg: true, schema: schema, tblRe: compileTableRe()}
if _, err := db.Exec(`CREATE SCHEMA IF NOT EXISTS ` + quoteIdent(schema)); err != nil {
_ = db.Close()
return nil, fmt.Errorf("create schema %q: %w", schema, err)
}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Ping verifies the database is reachable (used by the readiness probe).
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
func (s *Store) migrate() error {
// Execute statements individually: pgx's extended protocol rejects
// multi-statement Exec, and it keeps errors precise on both drivers.
for _, stmt := range splitStatements(schema) {
if _, err := s.db.Exec(s.qualify(stmt)); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
return nil
}
// splitStatements strips line comments and splits a DDL script on ";".
func splitStatements(sqlText string) []string {
var sb strings.Builder
for _, line := range strings.Split(sqlText, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "--") {
continue
}
sb.WriteString(line)
sb.WriteByte('\n')
}
var out []string
for _, p := range strings.Split(sb.String(), ";") {
if strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return out
}
// CountUsers returns the number of registered users.
func (s *Store) CountUsers() (int, error) {
var n int
err := s.queryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// Signup creates a workspace (tenant) and an owner user, returning the user.
func (s *Store) Signup(name, email, password, workspaceName string) (*User, error) {
email = strings.ToLower(strings.TrimSpace(email))
var exists int
if err := s.queryRow(`SELECT COUNT(*) FROM users WHERE email = ?`, email).Scan(&exists); err != nil {
return nil, err
}
if exists > 0 {
return nil, ErrEmailTaken
}
hash, err := auth.HashPassword(password)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if strings.TrimSpace(workspaceName) == "" {
workspaceName = defaultWorkspaceName(name, email)
}
ws := Workspace{ID: auth.NewID("ws_"), Name: workspaceName, Slug: slugify(workspaceName), CreatedAt: now}
// Ensure unique slug.
ws.Slug = s.uniqueSlug(ws.Slug)
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(s.rb(s.qualify(`INSERT INTO workspaces(id,name,slug,created_at) VALUES(?,?,?,?)`)),
ws.ID, ws.Name, ws.Slug, now.Format(time.RFC3339)); err != nil {
return nil, err
}
u := &User{ID: auth.NewID("usr_"), WorkspaceID: ws.ID, WorkspaceName: ws.Name, WorkspaceSlug: ws.Slug,
Name: strings.TrimSpace(name), Email: email, Role: "Owner", CreatedAt: now}
if _, err := tx.Exec(s.rb(s.qualify(`INSERT INTO users(id,workspace_id,name,email,password_hash,role,created_at) VALUES(?,?,?,?,?,?,?)`)),
u.ID, u.WorkspaceID, u.Name, u.Email, hash, u.Role, now.Format(time.RFC3339)); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return u, nil
}
// Login verifies credentials and returns the user.
func (s *Store) Login(email, password string) (*User, error) {
email = strings.ToLower(strings.TrimSpace(email))
var hash string
u := &User{}
var created string
row := s.queryRow(`
SELECT u.id,u.workspace_id,u.name,u.email,u.role,u.password_hash,u.created_at,w.name,w.slug
FROM users u JOIN workspaces w ON w.id = u.workspace_id WHERE u.email = ?`, email)
if err := row.Scan(&u.ID, &u.WorkspaceID, &u.Name, &u.Email, &u.Role, &hash, &created, &u.WorkspaceName, &u.WorkspaceSlug); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidLogin
}
return nil, err
}
if !auth.VerifyPassword(password, hash) {
return nil, ErrInvalidLogin
}
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil
}
// CreateSession issues a session token for a user.
func (s *Store) CreateSession(userID string) (string, error) {
token, err := auth.NewToken()
if err != nil {
return "", err
}
now := time.Now().UTC()
_, err = s.exec(`INSERT INTO sessions(token,user_id,created_at,expires_at) VALUES(?,?,?,?)`,
token, userID, now.Format(time.RFC3339), now.Add(SessionTTL).Format(time.RFC3339))
return token, err
}
// UserBySession resolves a session token to its user, enforcing expiry.
func (s *Store) UserBySession(token string) (*User, error) {
if token == "" {
return nil, ErrNotFound
}
u := &User{}
var created, expires string
row := s.queryRow(`
SELECT u.id,u.workspace_id,u.name,u.email,u.role,u.created_at,w.name,w.slug,s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
JOIN workspaces w ON w.id = u.workspace_id
WHERE s.token = ?`, token)
if err := row.Scan(&u.ID, &u.WorkspaceID, &u.Name, &u.Email, &u.Role, &created, &u.WorkspaceName, &u.WorkspaceSlug, &expires); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if exp, err := time.Parse(time.RFC3339, expires); err == nil && time.Now().After(exp) {
_ = s.DeleteSession(token)
return nil, ErrNotFound
}
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil
}
// DeleteSession removes a single session (logout).
func (s *Store) DeleteSession(token string) error {
_, err := s.exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
// DeleteUserSessions removes every session for a user (logout everywhere).
func (s *Store) DeleteUserSessions(userID string) error {
_, err := s.exec(`DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
func (s *Store) uniqueSlug(base string) string {
slug := base
for i := 1; ; i++ {
var n int
_ = s.queryRow(`SELECT COUNT(*) FROM workspaces WHERE slug = ?`, slug).Scan(&n)
if n == 0 {
return slug
}
slug = fmt.Sprintf("%s-%d", base, i)
}
}
func defaultWorkspaceName(name, email string) string {
if n := strings.TrimSpace(name); n != "" {
return n + "'s workspace"
}
if at := strings.IndexByte(email, '@'); at > 0 {
return email[:at] + "'s workspace"
}
return "Workspace"
}
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
prevDash := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
prevDash = false
default:
if !prevDash && b.Len() > 0 {
b.WriteByte('-')
prevDash = true
}
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "workspace"
}
return out
}