diff options
| author | Nikolay Govorov <me@govorov.online> | 2026-04-02 02:22:47 +0100 |
|---|---|---|
| committer | Nikolay Govorov <me@govorov.online> | 2026-04-02 02:22:47 +0100 |
| commit | 5bdc4e0bfcc0ff1df9ba73a2f13d820d1a974015 (patch) | |
| tree | ac08367a15109a7ff88602af62a45f3d3706f0be | |
| parent | f2bbe8c4e97e108c48d366a4018a73c66ca96026 (diff) | |
| download | tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip | |
Add daemon CLI with users management
Diffstat
| -rw-r--r-- | Taskfile.yml | 2 | +1 −1 |
| -rw-r--r-- | cmd/mirumd/config.go | 10 | +8 −2 |
| -rw-r--r-- | cmd/mirumd/main.go | 160 | +146 −14 |
| -rw-r--r-- | cmd/mirumd/server_admin.go | 46 | +46 −0 |
| -rw-r--r-- | go.mod | 5 | +4 −1 |
| -rw-r--r-- | go.sum | 8 | +8 −0 |
| -rw-r--r-- | internal/database/database.go | 107 | +102 −5 |
| -rw-r--r-- | pkg/mirumd.yaml | 2 | +2 −0 |
| -rw-r--r-- | proto/admin.proto | 36 | +36 −0 |
9 files changed, 353 insertions, 23 deletions
diff --git a/Taskfile.yml b/Taskfile.yml index ba31fe4..2373b8b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,7 +16,7 @@ tasks: generates: - internal/protocol/pb/*.pb.go cmds: - - protoc --go_out=. --go-grpc_out=. proto/mirum.proto + - protoc --go_out=. --go-grpc_out=. proto/mirum.proto proto/admin.proto test: desc: Run all tests diff --git a/cmd/mirumd/config.go b/cmd/mirumd/config.go index f124896..c856d01 100644 --- a/cmd/mirumd/config.go +++ b/cmd/mirumd/config.go @@ -13,8 +13,10 @@ import ( type config struct { WwwAddr string `yaml:"www_addr"` GrpcAddr string `yaml:"grpc_addr"` + AdminSocket string `yaml:"admin_socket"` DatabaseUri string `yaml:"database_uri"` WorkerSecret string `yaml:"secret"` + Pepper string `yaml:"pepper"` GitHubToken string `yaml:"token"` WebhookSecret string `yaml:"webhook_secret"` @@ -22,8 +24,9 @@ type config struct { func getConfig(filename string) (*config, error) { cfg := &config{ - GrpcAddr: ":2026", - WwwAddr: ":3000", + GrpcAddr: ":2026", + WwwAddr: ":3000", + AdminSocket: "/run/mirum/admin.sock", } data, err := os.ReadFile(filename) @@ -44,6 +47,9 @@ func getConfig(filename string) (*config, error) { if cfg.WebhookSecret == "" { return nil, fmt.Errorf("error: webhook_secret is required") } + if cfg.Pepper == "" { + return nil, fmt.Errorf("error: pepper is required") + } return cfg, nil } diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go index 86fe007..1a048d7 100644 --- a/cmd/mirumd/main.go +++ b/cmd/mirumd/main.go @@ -5,7 +5,6 @@ package main import ( "context" - "flag" "fmt" "log/slog" "net" @@ -19,24 +18,91 @@ import ( "dimidiumlabs/mirum/internal/supervisor" "github.com/coreos/go-systemd/v22/activation" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" ) func main() { - configFile := flag.String("config", "", "path to config file") - flag.Parse() + var socketPath string - if *configFile == "" { - slog.Error("-config required") + root := &cobra.Command{Use: "mirumd", Short: "Mirum CI server"} + root.PersistentFlags().StringVar(&socketPath, "socket", "", "admin socket path (default from config or /run/mirum/admin.sock)") + + daemonCmd := &cobra.Command{ + Use: "daemon", + Short: "Start the server", + Run: func(cmd *cobra.Command, args []string) { + configFile, _ := cmd.Flags().GetString("config") + daemon(configFile, socketPath) + }, + } + root.AddCommand(daemonCmd) + daemonCmd.Flags().String("config", "", "path to config file") + _ = daemonCmd.MarkFlagRequired("config") + + userCmd := &cobra.Command{Use: "user", Short: "Manage users"} + root.AddCommand(userCmd) + + userCreateCmd := &cobra.Command{ + Use: "create", + Short: "Create a user", + Run: func(cmd *cobra.Command, args []string) { + email, _ := cmd.Flags().GetString("email") + password, _ := cmd.Flags().GetString("password") + userCreate(socketPath, email, password) + }, + } + userCmd.AddCommand(userCreateCmd) + userCreateCmd.Flags().String("email", "", "user email") + userCreateCmd.Flags().String("password", "", "user password") + _ = userCreateCmd.MarkFlagRequired("email") + _ = userCreateCmd.MarkFlagRequired("password") + + setPasswordCmd := &cobra.Command{ + Use: "set-password", + Short: "Set user password", + Run: func(cmd *cobra.Command, args []string) { + email, _ := cmd.Flags().GetString("email") + password, _ := cmd.Flags().GetString("password") + userSetPassword(socketPath, email, password) + }, + } + userCmd.AddCommand(setPasswordCmd) + setPasswordCmd.Flags().String("email", "", "user email") + setPasswordCmd.Flags().String("password", "", "new password") + _ = setPasswordCmd.MarkFlagRequired("email") + _ = setPasswordCmd.MarkFlagRequired("password") + + deleteUserCmd := &cobra.Command{ + Use: "delete", + Short: "Delete a user", + Run: func(cmd *cobra.Command, args []string) { + email, _ := cmd.Flags().GetString("email") + userDelete(socketPath, email) + }, + } + userCmd.AddCommand(deleteUserCmd) + deleteUserCmd.Flags().String("email", "", "user email") + _ = deleteUserCmd.MarkFlagRequired("email") + + if err := root.Execute(); err != nil { os.Exit(1) } +} - cfg, err := getConfig(*configFile) +func daemon(configFile, socketFlag string) { + cfg, err := getConfig(configFile) if err != nil { slog.Error("config parsing failed", "err", err) os.Exit(1) } - slog.Info("config loaded", "configfile", *configFile) + if socketFlag != "" { + cfg.AdminSocket = socketFlag + } + + slog.Info("config loaded", "configfile", configFile) db, err := database.Open(context.Background(), cfg.DatabaseUri) if err != nil { @@ -64,14 +130,15 @@ func main() { wwwSrv := NewWwwServer(ctx, srv) grpcSrv := NewGrpcServer(ctx, srv, []byte(cfg.WorkerSecret)) + adminSrv := NewAdminServer(srv) - grpcLn, webLn, err := listeners(cfg) + grpcLn, webLn, adminLn, err := listeners(cfg) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - slog.Info("listening", "grpc", grpcLn.Addr(), "web", webLn.Addr()) + slog.Info("listening", "grpc", grpcLn.Addr(), "web", webLn.Addr(), "admin", cfg.AdminSocket) go func() { if err := wwwSrv.Serve(webLn); err != nil && err != http.ErrServerClosed { @@ -85,6 +152,12 @@ func main() { os.Exit(1) } }() + go func() { + if err := adminSrv.Serve(adminLn); err != nil { + slog.Error("admin server failed", "err", err) + os.Exit(1) + } + }() sup.Ready() go sup.StartWatchdog(ctx) @@ -101,6 +174,7 @@ func main() { srv.Close() + adminSrv.GracefulStop() wwwSrv.Shutdown(ctx) grpcSrv.GracefulStop() } @@ -108,16 +182,16 @@ func main() { // listeners returns gRPC and HTTP listeners. // With systemd socket activation it expects two named fds: "grpc" and "http". // Without socket activation it falls back to configured addresses. -func listeners(cfg *config) (grpcLn, webLn net.Listener, err error) { +func listeners(cfg *config) (grpcLn, webLn, adminLn net.Listener, err error) { named, err := activation.ListenersWithNames() if err != nil { - return nil, nil, fmt.Errorf("socket activation: %w", err) + return nil, nil, nil, fmt.Errorf("socket activation: %w", err) } if lns := named["grpc"]; len(lns) > 0 { grpcLn = lns[0] } else if grpcLn, err = net.Listen("tcp", cfg.GrpcAddr); err != nil { - return nil, nil, err + return nil, nil, nil, err } defer func() { if err != nil { @@ -128,7 +202,7 @@ func listeners(cfg *config) (grpcLn, webLn net.Listener, err error) { if lns := named["web"]; len(lns) > 0 { webLn = lns[0] } else if webLn, err = net.Listen("tcp", cfg.WwwAddr); err != nil { - return nil, nil, err + return nil, nil, nil, err } defer func() { if err != nil { @@ -136,5 +210,63 @@ func listeners(cfg *config) (grpcLn, webLn net.Listener, err error) { } }() - return grpcLn, webLn, nil + if adminLn, err = net.Listen("unix", cfg.AdminSocket); err != nil { + return nil, nil, nil, err + } + defer func() { + if err != nil { + _ = adminLn.Close() + } + }() + + return grpcLn, webLn, adminLn, nil +} + +func adminClient(socketPath string) pb.AdminClient { + if socketPath == "" { + socketPath = "/run/mirum/admin.sock" + } + conn, err := grpc.NewClient("unix://"+socketPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return pb.NewAdminClient(conn) +} + +func userCreate(socketPath, email, password string) { + resp, err := adminClient(socketPath).CreateUser(context.Background(), &pb.CreateUserRequest{ + Email: email, + Password: password, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(resp.Id) +} + +func userSetPassword(socketPath, email, password string) { + _, err := adminClient(socketPath).SetPassword(context.Background(), &pb.SetPasswordRequest{ + Email: email, + Password: password, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("ok") +} + +func userDelete(socketPath, email string) { + _, err := adminClient(socketPath).DeleteUser(context.Background(), &pb.DeleteUserRequest{ + Email: email, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("ok") } diff --git a/cmd/mirumd/server_admin.go b/cmd/mirumd/server_admin.go new file mode 100644 --- /dev/null +++ b/cmd/mirumd/server_admin.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + + "dimidiumlabs/mirum/internal/protocol/pb" + + "google.golang.org/grpc" +) + +func NewAdminServer(srv *server) *grpc.Server { + as := &adminService{srv: srv} + s := grpc.NewServer() + pb.RegisterAdminServer(s, as) + return s +} + +type adminService struct { + pb.UnimplementedAdminServer + srv *server +} + +func (a *adminService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) { + id, err := a.srv.db.CreateUser(ctx, req.Email, req.Password, []byte(a.srv.cfg.Pepper)) + if err != nil { + return nil, err + } + return &pb.CreateUserResponse{Id: id}, nil +} + +func (a *adminService) SetPassword(ctx context.Context, req *pb.SetPasswordRequest) (*pb.SetPasswordResponse, error) { + if err := a.srv.db.SetPassword(ctx, req.Email, req.Password, []byte(a.srv.cfg.Pepper)); err != nil { + return nil, err + } + return &pb.SetPasswordResponse{}, nil +} + +func (a *adminService) DeleteUser(ctx context.Context, req *pb.DeleteUserRequest) (*pb.DeleteUserResponse, error) { + if err := a.srv.db.DeleteUser(ctx, req.Email); err != nil { + return nil, err + } + return &pb.DeleteUserResponse{}, nil +} diff --git a/go.mod b/go.mod index 13f91a3..5237b98 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,9 @@ require ( github.com/coreos/go-systemd/v22 v22.7.0 github.com/jackc/pgx/v5 v5.9.1 github.com/jackc/tern/v2 v2.3.6 + github.com/spf13/cobra v1.8.0 go.starlark.net v0.0.0-20260326113308-fadfc96def35 + golang.org/x/crypto v0.49.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -19,6 +21,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -26,7 +29,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect - golang.org/x/crypto v0.49.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect diff --git a/go.sum b/go.sum index 9d587a7..12406b8 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -27,6 +28,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -49,10 +52,15 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/database/database.go b/internal/database/database.go index 52d915d..1f88692 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -5,17 +5,34 @@ package database import ( "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "errors" + "fmt" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/tern/v2/migrate" + "golang.org/x/crypto/argon2" +) + +const ( + argonMemory = 64 * 1024 // 64 MB + argonTime = 3 + argonThreads = 2 + argonKeyLen = 32 + saltLen = 16 ) var ( - errOpen = errors.New("database: failed to open") - errPing = errors.New("database: failed to ping") - errAcquire = errors.New("database: failed to acquire connection") - errMigrate = errors.New("database: failed to create migrator") + errOpen = errors.New("database: failed to open") + errPing = errors.New("database: failed to ping") + errAcquire = errors.New("database: failed to acquire connection") + errMigrate = errors.New("database: failed to create migrator") + errCreateUser = errors.New("database: failed to create user") + errSetPassword = errors.New("database: failed to set password") + errDeleteUser = errors.New("database: failed to delete user") ) // DB wraps a pgx connection pool. @@ -59,10 +76,90 @@ func (db *DB) Migrate(ctx context.Context) error { id UUID PRIMARY KEY DEFAULT uuidv7(), email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ )`, `DROP TABLE users`, ) return migrator.Migrate(ctx) } + +// CreateUser hashes the password with argon2id and inserts a new user. +// The pepper is a server-side secret not stored in the database. +func (db *DB) CreateUser(ctx context.Context, email, password string, pepper []byte) (string, error) { + hash, err := hashPassword(password, pepper) + if err != nil { + return "", err + } + + var id string + err = db.Pool.QueryRow(ctx, + `INSERT INTO users (email, password) VALUES ($1, $2) RETURNING id`, + email, hash, + ).Scan(&id) + if err != nil { + return "", errors.Join(errCreateUser, err) + } + + return id, nil +} + +// SetPassword updates the password for a user identified by email. +func (db *DB) SetPassword(ctx context.Context, email, password string, pepper []byte) error { + hash, err := hashPassword(password, pepper) + if err != nil { + return err + } + + tag, err := db.Pool.Exec(ctx, + `UPDATE users SET password = $1 WHERE email = $2`, + hash, email, + ) + if err != nil { + return errors.Join(errSetPassword, err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("user not found: %s", email) + } + + return nil +} + +// DeleteUser clears all fields but keeps the row to preserve the id. +func (db *DB) DeleteUser(ctx context.Context, email string) error { + tag, err := db.Pool.Exec(ctx, + `UPDATE users SET email = '<invalid>', password = '<invalid>', deleted_at = now() WHERE email = $1`, + email, + ) + if err != nil { + return errors.Join(errDeleteUser, err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("user not found: %s", email) + } + return nil +} + +// hashPassword produces a PHC-format string: +// $argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash> +func hashPassword(password string, pepper []byte) (string, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + // Apply pepper: HMAC-SHA256(pepper, password) + mac := hmac.New(sha256.New, pepper) + mac.Write([]byte(password)) + peppered := mac.Sum(nil) + + key := argon2.IDKey(peppered, salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ), nil +} diff --git a/pkg/mirumd.yaml b/pkg/mirumd.yaml index c3041b2..f140100 100644 --- a/pkg/mirumd.yaml +++ b/pkg/mirumd.yaml @@ -7,5 +7,7 @@ grpc_addr: :2026 www_addr: :3000 secret: "" token: "" +pepper: "" dsn: "" +admin_socket: /run/mirum/admin.sock script: .mirum/main.star diff --git a/proto/admin.proto b/proto/admin.proto new file mode 100644 --- /dev/null +++ b/proto/admin.proto @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +syntax = "proto3"; + +package mirum; + +option go_package = "internal/protocol/pb"; + +service Admin { + rpc CreateUser(CreateUserRequest) returns (CreateUserResponse); + rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse); + rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse); +} + +message CreateUserRequest { + string email = 1; + string password = 2; +} + +message CreateUserResponse { + string id = 1; +} + +message SetPasswordRequest { + string email = 1; + string password = 2; +} + +message SetPasswordResponse {} + +message DeleteUserRequest { + string email = 1; +} + +message DeleteUserResponse {} |
