aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-04-04 02:42:29 +0100
committerNikolay Govorov <me@govorov.online>2026-04-04 02:47:06 +0100
commitc3c9802b8a2a16077793498b995b6a632f4641c5 (patch)
tree0f069976354f77b52e348befdaa47a7f0345ba36
parenta82b57af5f68ce74c18a1d3ba1d839222d1edfd5 (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Cleanup admin api
Diffstat
-rw-r--r--REUSE.toml1+1 −0
-rw-r--r--cmd/mirumd/main.go109+69 −40
-rw-r--r--cmd/mirumd/server_admin.go421+335 −86
-rw-r--r--go.mod14+13 −1
-rw-r--r--go.sum35+35 −0
-rw-r--r--internal/database/database.go30+16 −14
-rw-r--r--internal/database/organization.go428+293 −135
-rw-r--r--internal/database/user.go290+219 −71
-rw-r--r--internal/database/validate.go60+60 −0
-rw-r--r--internal/database/worker.go146+102 −44
-rw-r--r--proto/admin.proto282+199 −83
-rw-r--r--proto/buf.lock6+6 −0
-rw-r--r--proto/buf.yaml6+6 −0
13 files changed, 1354 insertions, 474 deletions
diff --git a/REUSE.toml b/REUSE.toml
index 6875d1a..f387a83 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -12,6 +12,7 @@ path = [
"README.md",
"pkg/logo.svg",
"pkg/dl/*",
+ "proto/buf.*"
]
SPDX-FileCopyrightText = "2026 Nikolay Govorov <me@govorov.online>"
SPDX-License-Identifier = "AGPL-3.0-or-later"
diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go
index b7210e6..b0b8368 100644
--- a/cmd/mirumd/main.go
+++ b/cmd/mirumd/main.go
@@ -22,6 +22,7 @@ import (
"dimidiumlabs/mirum/internal/forges"
"connectrpc.com/connect"
+ "github.com/google/uuid"
"dimidiumlabs/mirum/internal/protocol/pb"
"dimidiumlabs/mirum/internal/protocol/pb/pbconnect"
@@ -187,12 +188,10 @@ func main() {
Use: "list",
Short: "List organizations",
Run: func(cmd *cobra.Command, args []string) {
- email, _ := cmd.Flags().GetString("user")
- orgList(socketPath, email)
+ orgList(socketPath)
},
}
orgCmd.AddCommand(orgListCmd)
- orgListCmd.Flags().String("user", "", "filter by user email (optional)")
orgMemberAddCmd := &cobra.Command{
Use: "add-member",
@@ -449,6 +448,35 @@ func adminClient(socketPath string) pbconnect.AdminClient {
)
}
+func cliUserRef(email string) *pb.UserRef {
+ return &pb.UserRef{Ref: &pb.UserRef_Email{Email: email}}
+}
+
+func cliOrgRef(slug string) *pb.OrgRef {
+ return &pb.OrgRef{Ref: &pb.OrgRef_Slug{Slug: slug}}
+}
+
+func cliRoleToProto(role string) pb.Role {
+ switch role {
+ case "owner":
+ return pb.Role_ROLE_OWNER
+ case "admin":
+ return pb.Role_ROLE_ADMIN
+ case "member":
+ return pb.Role_ROLE_MEMBER
+ default:
+ return pb.Role_ROLE_NONE
+ }
+}
+
+func cliUUID(b []byte) string {
+ if len(b) == 16 {
+ u, _ := uuid.FromBytes(b)
+ return u.String()
+ }
+ return fmt.Sprintf("%x", b)
+}
+
func userCreate(socketPath, email, password string) {
resp, err := adminClient(socketPath).UserCreate(context.Background(), connect.NewRequest(&pb.UserCreateRequest{
Email: email,
@@ -458,13 +486,13 @@ func userCreate(socketPath, email, password string) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
- fmt.Println(resp.Msg.Id)
+ fmt.Println(cliUUID(resp.Msg.Id))
}
func userSetPassword(socketPath, email, password string) {
- _, err := adminClient(socketPath).UserSetPassword(context.Background(), connect.NewRequest(&pb.UserSetPasswordRequest{
- Email: email,
- Password: password,
+ _, err := adminClient(socketPath).UserUpdate(context.Background(), connect.NewRequest(&pb.UserUpdateRequest{
+ User: cliUserRef(email),
+ Password: &password,
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -475,7 +503,7 @@ func userSetPassword(socketPath, email, password string) {
func userDelete(socketPath, email string) {
_, err := adminClient(socketPath).UserDelete(context.Background(), connect.NewRequest(&pb.UserDeleteRequest{
- Email: email,
+ User: cliUserRef(email),
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -500,19 +528,24 @@ func workerAdd(socketPath, pubkeyB64 string) {
fmt.Fprintln(os.Stderr, "not an ed25519 key")
os.Exit(1)
}
- resp, err := adminClient(socketPath).WorkerAdd(context.Background(), connect.NewRequest(&pb.WorkerAddRequest{
+ resp, err := adminClient(socketPath).WorkerCreate(context.Background(), connect.NewRequest(&pb.WorkerCreateRequest{
PublicKey: edKey,
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
- fmt.Println(resp.Msg.Id)
+ fmt.Println(cliUUID(resp.Msg.Id))
}
func workerRevoke(socketPath, id string) {
- _, err := adminClient(socketPath).WorkerRevoke(context.Background(), connect.NewRequest(&pb.WorkerRevokeRequest{
- Id: id,
+ uid, err := uuid.Parse(id)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "invalid uuid:", err)
+ os.Exit(1)
+ }
+ _, err = adminClient(socketPath).WorkerDelete(context.Background(), connect.NewRequest(&pb.WorkerDeleteRequest{
+ Id: uid[:],
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -529,27 +562,27 @@ func workerList(socketPath string) {
}
for _, w := range resp.Msg.Workers {
created := w.CreatedAt.AsTime().Format(time.DateOnly)
- fmt.Printf("%s\t%s\t%s\n", w.Id, base64.StdEncoding.EncodeToString(w.PublicKey), created)
+ fmt.Printf("%s\t%s\t%s\n", cliUUID(w.Id), base64.StdEncoding.EncodeToString(w.PublicKey), created)
}
}
func orgCreate(socketPath, name, slug string, public bool, ownerEmail string) {
resp, err := adminClient(socketPath).OrgCreate(context.Background(), connect.NewRequest(&pb.OrgCreateRequest{
- Name: name,
- Slug: slug,
- Public: public,
- OwnerEmail: ownerEmail,
+ Name: name,
+ Slug: slug,
+ Public: public,
+ Owner: cliUserRef(ownerEmail),
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
- fmt.Println(resp.Msg.Id)
+ fmt.Println(cliUUID(resp.Msg.Id))
}
func orgDelete(socketPath, slug string) {
_, err := adminClient(socketPath).OrgDelete(context.Background(), connect.NewRequest(&pb.OrgDeleteRequest{
- Slug: slug,
+ Org: cliOrgRef(slug),
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -559,10 +592,10 @@ func orgDelete(socketPath, slug string) {
}
func orgRename(socketPath, slug, newName, newSlug string) {
- _, err := adminClient(socketPath).OrgRename(context.Background(), connect.NewRequest(&pb.OrgRenameRequest{
- Slug: slug,
- NewName: newName,
- NewSlug: newSlug,
+ _, err := adminClient(socketPath).OrgUpdate(context.Background(), connect.NewRequest(&pb.OrgUpdateRequest{
+ Org: cliOrgRef(slug),
+ Name: &newName,
+ Slug: &newSlug,
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -571,12 +604,8 @@ func orgRename(socketPath, slug, newName, newSlug string) {
fmt.Println("ok")
}
-func orgList(socketPath, email string) {
- req := &pb.OrgListRequest{}
- if email != "" {
- req.UserEmail = &email
- }
- resp, err := adminClient(socketPath).OrgList(context.Background(), connect.NewRequest(req))
+func orgList(socketPath string) {
+ resp, err := adminClient(socketPath).OrgList(context.Background(), connect.NewRequest(&pb.OrgListRequest{}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -592,9 +621,9 @@ func orgList(socketPath, email string) {
func orgMemberAdd(socketPath, orgSlug, email, role string) {
_, err := adminClient(socketPath).OrgMemberAdd(context.Background(), connect.NewRequest(&pb.OrgMemberAddRequest{
- OrgSlug: orgSlug,
- Email: email,
- Role: role,
+ Org: cliOrgRef(orgSlug),
+ User: cliUserRef(email),
+ Role: cliRoleToProto(role),
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -605,8 +634,8 @@ func orgMemberAdd(socketPath, orgSlug, email, role string) {
func orgMemberRemove(socketPath, orgSlug, email string) {
_, err := adminClient(socketPath).OrgMemberRemove(context.Background(), connect.NewRequest(&pb.OrgMemberRemoveRequest{
- OrgSlug: orgSlug,
- Email: email,
+ Org: cliOrgRef(orgSlug),
+ User: cliUserRef(email),
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -616,10 +645,10 @@ func orgMemberRemove(socketPath, orgSlug, email string) {
}
func orgMemberSetRole(socketPath, orgSlug, email, role string) {
- _, err := adminClient(socketPath).OrgMemberSetRole(context.Background(), connect.NewRequest(&pb.OrgMemberSetRoleRequest{
- OrgSlug: orgSlug,
- Email: email,
- Role: role,
+ _, err := adminClient(socketPath).OrgMemberUpdate(context.Background(), connect.NewRequest(&pb.OrgMemberUpdateRequest{
+ Org: cliOrgRef(orgSlug),
+ User: cliUserRef(email),
+ Role: cliRoleToProto(role),
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -630,13 +659,13 @@ func orgMemberSetRole(socketPath, orgSlug, email, role string) {
func orgMemberList(socketPath, orgSlug string) {
resp, err := adminClient(socketPath).OrgMemberList(context.Background(), connect.NewRequest(&pb.OrgMemberListRequest{
- OrgSlug: orgSlug,
+ Org: cliOrgRef(orgSlug),
}))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, m := range resp.Msg.Members {
- fmt.Printf("%s\t%s\t%s\n", m.Email, m.Role, m.JoinedAt.AsTime().Format(time.DateOnly))
+ fmt.Printf("%s\t%s\t%s\n", m.User.Email, m.Role, m.JoinedAt.AsTime().Format(time.DateOnly))
}
}
diff --git a/cmd/mirumd/server_admin.go b/cmd/mirumd/server_admin.go
index 2962887..94ab144 100644
--- a/cmd/mirumd/server_admin.go
+++ b/cmd/mirumd/server_admin.go
@@ -5,11 +5,13 @@ package main
import (
"context"
- "crypto/ed25519"
+ "errors"
"fmt"
"net/http"
"connectrpc.com/connect"
+ "connectrpc.com/validate"
+ "github.com/google/uuid"
"google.golang.org/protobuf/types/known/timestamppb"
"dimidiumlabs/mirum/internal/database"
@@ -19,7 +21,9 @@ import (
func NewAdminServer(srv *server) *http.Server {
as := &adminService{srv: srv}
- path, handler := pbconnect.NewAdminHandler(as)
+ path, handler := pbconnect.NewAdminHandler(as,
+ connect.WithInterceptors(validate.NewInterceptor()),
+ )
mux := http.NewServeMux()
mux.Handle(path, handler)
return &http.Server{Handler: mux}
@@ -30,143 +34,388 @@ type adminService struct {
srv *server
}
+// --- Error mapping ---
+
+func mapErr(err error) error {
+ if err == nil {
+ return nil
+ }
+ switch {
+ case errors.Is(err, database.ErrUserNotFound),
+ errors.Is(err, database.ErrOrgNotFound),
+ errors.Is(err, database.ErrWorkerNotFound),
+ errors.Is(err, database.ErrNotMember):
+ return connect.NewError(connect.CodeNotFound, err)
+ case errors.Is(err, database.ErrAlreadyMember),
+ errors.Is(err, database.ErrSlugTaken),
+ errors.Is(err, database.ErrEmailTaken):
+ return connect.NewError(connect.CodeAlreadyExists, err)
+ case errors.Is(err, database.ErrLastOwner),
+ errors.Is(err, database.ErrSoleOwner):
+ return connect.NewError(connect.CodeFailedPrecondition, err)
+ case errors.Is(err, database.ErrInvalidSlug),
+ errors.Is(err, database.ErrInvalidRole):
+ return connect.NewError(connect.CodeInvalidArgument, err)
+ case errors.Is(err, database.ErrFilterNotImplemented):
+ return connect.NewError(connect.CodeUnimplemented, err)
+ default:
+ return connect.NewError(connect.CodeInternal, fmt.Errorf("internal error"))
+ }
+}
+
+// --- Ref converters ---
+
+func userRef(r *pb.UserRef) database.UserRef {
+ switch v := r.GetRef().(type) {
+ case *pb.UserRef_Id:
+ return database.UserByID(uuid.UUID(v.Id))
+ case *pb.UserRef_Email:
+ return database.UserByEmail(v.Email)
+ default:
+ return database.UserByID(uuid.Nil)
+ }
+}
+
+func orgRef(r *pb.OrgRef) database.OrgRef {
+ switch v := r.GetRef().(type) {
+ case *pb.OrgRef_Id:
+ return database.OrgByID(uuid.UUID(v.Id))
+ case *pb.OrgRef_Slug:
+ return database.OrgBySlug(v.Slug)
+ default:
+ return database.OrgByID(uuid.Nil)
+ }
+}
+
+// --- Role converters ---
+
+var roleToString = map[pb.Role]string{
+ pb.Role_ROLE_OWNER: "owner",
+ pb.Role_ROLE_ADMIN: "admin",
+ pb.Role_ROLE_MEMBER: "member",
+}
+
+var roleToProto = map[string]pb.Role{
+ "owner": pb.Role_ROLE_OWNER,
+ "admin": pb.Role_ROLE_ADMIN,
+ "member": pb.Role_ROLE_MEMBER,
+}
+
+// --- Page helpers ---
+
+const (
+ defaultPageSize = 50
+ maxPageSize = 200
+)
+
+func pageParams(p *pb.PageRequest) (cursor uuid.UUID, limit int) {
+ limit = defaultPageSize
+ if p != nil {
+ if p.PageSize > 0 && int(p.PageSize) < maxPageSize {
+ limit = int(p.PageSize)
+ } else if int(p.PageSize) >= maxPageSize {
+ limit = maxPageSize
+ }
+ if len(p.Cursor) == 16 {
+ cursor = uuid.UUID(p.Cursor)
+ }
+ }
+ return
+}
+
+func pageResponse(items int, limit int, lastID uuid.UUID, total int) *pb.PageResponse {
+ resp := &pb.PageResponse{TotalCount: int32(total)}
+ if items == limit {
+ resp.NextCursor = lastID[:]
+ }
+ return resp
+}
+
+// --- Proto converters ---
+
+func userToProto(u database.User) *pb.User {
+ return &pb.User{
+ Id: u.ID[:], Email: u.Email, CreatedAt: timestamppb.New(u.CreatedAt),
+ }
+}
+
+func orgToProto(o database.Organization) *pb.Org {
+ return &pb.Org{
+ Id: o.ID[:], Name: o.Name, Slug: o.Slug,
+ Public: o.Public, CreatedAt: timestamppb.New(o.CreatedAt),
+ }
+}
+
+func memberToProto(m database.OrgMember) *pb.OrgMemberInfo {
+ return &pb.OrgMemberInfo{
+ User: userToProto(m.User), Role: roleToProto[m.Role],
+ JoinedAt: timestamppb.New(m.JoinedAt),
+ }
+}
+
+func workerToProto(w database.Worker) *pb.Worker {
+ pw := &pb.Worker{
+ Id: w.ID[:], PublicKey: w.PublicKey, CreatedAt: timestamppb.New(w.CreatedAt),
+ }
+ if w.OrgID != nil {
+ pw.OrgId = w.OrgID[:]
+ }
+ return pw
+}
+
+// --- User handlers ---
+
func (a *adminService) UserCreate(ctx context.Context, req *connect.Request[pb.UserCreateRequest]) (*connect.Response[pb.UserCreateResponse], error) {
id, err := a.srv.db.UserCreate(ctx, req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper))
if err != nil {
- return nil, err
+ return nil, mapErr(err)
}
- return connect.NewResponse(&pb.UserCreateResponse{Id: id}), nil
+ return connect.NewResponse(&pb.UserCreateResponse{Id: id[:]}), nil
+}
+
+func (a *adminService) UserGet(ctx context.Context, req *connect.Request[pb.UserGetRequest]) (*connect.Response[pb.UserGetResponse], error) {
+ u, err := a.srv.db.GetUser(ctx, userRef(req.Msg.User))
+ if err != nil {
+ return nil, mapErr(err)
+ }
+ return connect.NewResponse(&pb.UserGetResponse{User: userToProto(*u)}), nil
+}
+
+func (a *adminService) UserList(ctx context.Context, req *connect.Request[pb.UserListRequest]) (*connect.Response[pb.UserListResponse], error) {
+ cursor, limit := pageParams(req.Msg.Page)
+ filter := ""
+ if req.Msg.Filter != nil {
+ filter = *req.Msg.Filter
+ }
+
+ users, total, err := a.srv.db.ListUsers(ctx, cursor, limit, filter)
+ if err != nil {
+ return nil, mapErr(err)
+ }
+
+ out := make([]*pb.User, len(users))
+ for i := range users {
+ out[i] = userToProto(users[i])
+ }
+
+ var lastID uuid.UUID
+ if len(users) > 0 {
+ lastID = users[len(users)-1].ID
+ }
+
+ return connect.NewResponse(&pb.UserListResponse{
+ Page: pageResponse(len(users), limit, lastID, total),
+ Users: out,
+ }), nil
}
-func (a *adminService) UserSetPassword(ctx context.Context, req *connect.Request[pb.UserSetPasswordRequest]) (*connect.Response[pb.UserSetPasswordResponse], error) {
- if err := a.srv.db.UserSetPassword(ctx, req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil {
- return nil, err
+func (a *adminService) UserUpdate(ctx context.Context, req *connect.Request[pb.UserUpdateRequest]) (*connect.Response[pb.UserUpdateResponse], error) {
+ if err := a.srv.db.UserUpdate(ctx, userRef(req.Msg.User), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil {
+ return nil, mapErr(err)
}
- return connect.NewResponse(&pb.UserSetPasswordResponse{}), nil
+ return connect.NewResponse(&pb.UserUpdateResponse{}), nil
}
func (a *adminService) UserDelete(ctx context.Context, req *connect.Request[pb.UserDeleteRequest]) (*connect.Response[pb.UserDeleteResponse], error) {
- if err := a.srv.db.UserDelete(ctx, req.Msg.Email); err != nil {
- return nil, err
+ if err := a.srv.db.UserDelete(ctx, userRef(req.Msg.User)); err != nil {
+ return nil, mapErr(err)
}
return connect.NewResponse(&pb.UserDeleteResponse{}), nil
}
-func (a *adminService) WorkerAdd(ctx context.Context, req *connect.Request[pb.WorkerAddRequest]) (*connect.Response[pb.WorkerAddResponse], error) {
- if len(req.Msg.PublicKey) != ed25519.PublicKeySize {
- return nil, fmt.Errorf("invalid public key: expected %d bytes, got %d", ed25519.PublicKeySize, len(req.Msg.PublicKey))
+// --- Org handlers ---
+
+func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.OrgCreateRequest]) (*connect.Response[pb.OrgCreateResponse], error) {
+ slug, err := database.ValidateSlug(req.Msg.Slug)
+ if err != nil {
+ return nil, mapErr(err)
}
- id, err := a.srv.db.AddWorker(ctx, req.Msg.PublicKey)
+ id, err := a.srv.db.CreateOrganization(ctx, req.Msg.Name, slug, req.Msg.Public, userRef(req.Msg.Owner))
if err != nil {
- return nil, err
+ return nil, mapErr(err)
}
- return connect.NewResponse(&pb.WorkerAddResponse{Id: id}), nil
+ return connect.NewResponse(&pb.OrgCreateResponse{Id: id[:]}), nil
}
-func (a *adminService) WorkerRevoke(ctx context.Context, req *connect.Request[pb.WorkerRevokeRequest]) (*connect.Response[pb.WorkerRevokeResponse], error) {
- if err := a.srv.db.WorkerRevoke(ctx, req.Msg.Id); err != nil {
- return nil, err
+func (a *adminService) OrgGet(ctx context.Context, req *connect.Request[pb.OrgGetRequest]) (*connect.Response[pb.OrgGetResponse], error) {
+ o, err := a.srv.db.GetOrg(ctx, orgRef(req.Msg.Org))
+ if err != nil {
+ return nil, mapErr(err)
}
- return connect.NewResponse(&pb.WorkerRevokeResponse{}), nil
+ return connect.NewResponse(&pb.OrgGetResponse{Org: orgToProto(*o)}), nil
}
-func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.WorkerListRequest]) (*connect.Response[pb.WorkerListResponse], error) {
- workers, err := a.srv.db.ListWorkers(ctx)
+func (a *adminService) OrgList(ctx context.Context, req *connect.Request[pb.OrgListRequest]) (*connect.Response[pb.OrgListResponse], error) {
+ cursor, limit := pageParams(req.Msg.Page)
+ filter := ""
+ if req.Msg.Filter != nil {
+ filter = *req.Msg.Filter
+ }
+
+ orgs, total, err := a.srv.db.ListOrganizations(ctx, cursor, limit, filter)
if err != nil {
- return nil, err
- }
- pbWorkers := make([]*pb.Worker, len(workers))
- for i, w := range workers {
- pbWorkers[i] = &pb.Worker{
- Id: w.ID,
- PublicKey: w.PublicKey,
- CreatedAt: timestamppb.New(w.CreatedAt),
- }
+ return nil, mapErr(err)
+ }
+
+ out := make([]*pb.Org, len(orgs))
+ for i := range orgs {
+ out[i] = orgToProto(orgs[i])
}
- return connect.NewResponse(&pb.WorkerListResponse{Workers: pbWorkers}), nil
+
+ var lastID uuid.UUID
+ if len(orgs) > 0 {
+ lastID = orgs[len(orgs)-1].ID
+ }
+
+ return connect.NewResponse(&pb.OrgListResponse{
+ Page: pageResponse(len(orgs), limit, lastID, total),
+ Organizations: out,
+ }), nil
}
-func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.OrgCreateRequest]) (*connect.Response[pb.OrgCreateResponse], error) {
- id, err := a.srv.db.CreateOrganization(ctx, req.Msg.Name, req.Msg.Slug, req.Msg.Public, req.Msg.OwnerEmail)
- if err != nil {
- return nil, err
+func (a *adminService) OrgUpdate(ctx context.Context, req *connect.Request[pb.OrgUpdateRequest]) (*connect.Response[pb.OrgUpdateResponse], error) {
+ var slug *string
+ if req.Msg.Slug != nil {
+ s, err := database.ValidateSlug(*req.Msg.Slug)
+ if err != nil {
+ return nil, mapErr(err)
+ }
+ slug = &s
}
- return connect.NewResponse(&pb.OrgCreateResponse{Id: id}), nil
+ if err := a.srv.db.UpdateOrganization(ctx, orgRef(req.Msg.Org), req.Msg.Name, slug, req.Msg.Public); err != nil {
+ return nil, mapErr(err)
+ }
+ return connect.NewResponse(&pb.OrgUpdateResponse{}), nil
}
func (a *adminService) OrgDelete(ctx context.Context, req *connect.Request[pb.OrgDeleteRequest]) (*connect.Response[pb.OrgDeleteResponse], error) {
- if err := a.srv.db.DeleteOrganization(ctx, req.Msg.Slug); err != nil {
- return nil, err
+ if err := a.srv.db.DeleteOrganization(ctx, orgRef(req.Msg.Org)); err != nil {
+ return nil, mapErr(err)
}
return connect.NewResponse(&pb.OrgDeleteResponse{}), nil
}
-func (a *adminService) OrgRename(ctx context.Context, req *connect.Request[pb.OrgRenameRequest]) (*connect.Response[pb.OrgRenameResponse], error) {
- if err := a.srv.db.RenameOrganization(ctx, req.Msg.Slug, req.Msg.NewName, req.Msg.NewSlug); err != nil {
- return nil, err
+// --- OrgMember handlers ---
+
+func (a *adminService) OrgMemberAdd(ctx context.Context, req *connect.Request[pb.OrgMemberAddRequest]) (*connect.Response[pb.OrgMemberAddResponse], error) {
+ role, ok := roleToString[req.Msg.Role]
+ if !ok {
+ return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid role"))
+ }
+ if err := a.srv.db.AddOrgMember(ctx, orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil {
+ return nil, mapErr(err)
}
- return connect.NewResponse(&pb.OrgRenameResponse{}), nil
+ return connect.NewResponse(&pb.OrgMemberAddResponse{}), nil
}
-func (a *adminService) OrgList(ctx context.Context, req *connect.Request[pb.OrgListRequest]) (*connect.Response[pb.OrgListResponse], error) {
- var (
- orgs []database.Organization
- err error
- )
- if req.Msg.UserEmail != nil {
- orgs, err = a.srv.db.ListUserOrganizations(ctx, *req.Msg.UserEmail)
- } else {
- orgs, err = a.srv.db.ListAllOrganizations(ctx)
+func (a *adminService) OrgMemberGet(ctx context.Context, req *connect.Request[pb.OrgMemberGetRequest]) (*connect.Response[pb.OrgMemberGetResponse], error) {
+ m, err := a.srv.db.GetOrgMember(ctx, orgRef(req.Msg.Org), userRef(req.Msg.User))
+ if err != nil {
+ return nil, mapErr(err)
}
+ return connect.NewResponse(&pb.OrgMemberGetResponse{Member: memberToProto(*m)}), nil
+}
+
+func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[pb.OrgMemberListRequest]) (*connect.Response[pb.OrgMemberListResponse], error) {
+ cursor, limit := pageParams(req.Msg.Page)
+ filter := ""
+ if req.Msg.Filter != nil {
+ filter = *req.Msg.Filter
+ }
+
+ members, total, err := a.srv.db.ListOrgMembers(ctx, orgRef(req.Msg.Org), cursor, limit, filter)
if err != nil {
- return nil, err
- }
- pbOrgs := make([]*pb.Org, len(orgs))
- for i, o := range orgs {
- pbOrgs[i] = &pb.Org{
- Id: o.ID,
- Name: o.Name,
- Slug: o.Slug,
- Public: o.Public,
- CreatedAt: timestamppb.New(o.CreatedAt),
- }
+ return nil, mapErr(err)
+ }
+
+ out := make([]*pb.OrgMemberInfo, len(members))
+ for i := range members {
+ out[i] = memberToProto(members[i])
+ }
+
+ var lastID uuid.UUID
+ if len(members) > 0 {
+ lastID = members[len(members)-1].User.ID
}
- return connect.NewResponse(&pb.OrgListResponse{Organizations: pbOrgs}), nil
+
+ return connect.NewResponse(&pb.OrgMemberListResponse{
+ Page: pageResponse(len(members), limit, lastID, total),
+ Members: out,
+ }), nil
}
-func (a *adminService) OrgMemberAdd(ctx context.Context, req *connect.Request[pb.OrgMemberAddRequest]) (*connect.Response[pb.OrgMemberAddResponse], error) {
- if err := a.srv.db.AddOrgMember(ctx, req.Msg.OrgSlug, req.Msg.Email, req.Msg.Role); err != nil {
- return nil, err
+func (a *adminService) OrgMemberUpdate(ctx context.Context, req *connect.Request[pb.OrgMemberUpdateRequest]) (*connect.Response[pb.OrgMemberUpdateResponse], error) {
+ role, ok := roleToString[req.Msg.Role]
+ if !ok {
+ return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid role"))
}
- return connect.NewResponse(&pb.OrgMemberAddResponse{}), nil
+ if err := a.srv.db.UpdateOrgMemberRole(ctx, orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil {
+ return nil, mapErr(err)
+ }
+ return connect.NewResponse(&pb.OrgMemberUpdateResponse{}), nil
}
func (a *adminService) OrgMemberRemove(ctx context.Context, req *connect.Request[pb.OrgMemberRemoveRequest]) (*connect.Response[pb.OrgMemberRemoveResponse], error) {
- if err := a.srv.db.RemoveOrgMember(ctx, req.Msg.OrgSlug, req.Msg.Email); err != nil {
- return nil, err
+ if err := a.srv.db.RemoveOrgMember(ctx, orgRef(req.Msg.Org), userRef(req.Msg.User)); err != nil {
+ return nil, mapErr(err)
}
return connect.NewResponse(&pb.OrgMemberRemoveResponse{}), nil
}
-func (a *adminService) OrgMemberSetRole(ctx context.Context, req *connect.Request[pb.OrgMemberSetRoleRequest]) (*connect.Response[pb.OrgMemberSetRoleResponse], error) {
- if err := a.srv.db.ChangeOrgMemberRole(ctx, req.Msg.OrgSlug, req.Msg.Email, req.Msg.Role); err != nil {
- return nil, err
+// --- Worker handlers ---
+
+func (a *adminService) WorkerCreate(ctx context.Context, req *connect.Request[pb.WorkerCreateRequest]) (*connect.Response[pb.WorkerCreateResponse], error) {
+ var org *database.OrgRef
+ if req.Msg.Org != nil {
+ r := orgRef(req.Msg.Org)
+ org = &r
+ }
+ id, err := a.srv.db.CreateWorker(ctx, req.Msg.PublicKey, org)
+ if err != nil {
+ return nil, mapErr(err)
}
- return connect.NewResponse(&pb.OrgMemberSetRoleResponse{}), nil
+ return connect.NewResponse(&pb.WorkerCreateResponse{Id: id[:]}), nil
}
-func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[pb.OrgMemberListRequest]) (*connect.Response[pb.OrgMemberListResponse], error) {
- members, err := a.srv.db.ListOrgMembers(ctx, req.Msg.OrgSlug)
+func (a *adminService) WorkerGet(ctx context.Context, req *connect.Request[pb.WorkerGetRequest]) (*connect.Response[pb.WorkerGetResponse], error) {
+ w, err := a.srv.db.GetWorker(ctx, uuid.UUID(req.Msg.Id))
if err != nil {
- return nil, err
- }
- pbMembers := make([]*pb.OrgMemberInfo, len(members))
- for i, m := range members {
- pbMembers[i] = &pb.OrgMemberInfo{
- Email: m.User.Email,
- Role: m.Role,
- JoinedAt: timestamppb.New(m.JoinedAt),
- }
+ return nil, mapErr(err)
+ }
+ return connect.NewResponse(&pb.WorkerGetResponse{Worker: workerToProto(*w)}), nil
+}
+
+func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.WorkerListRequest]) (*connect.Response[pb.WorkerListResponse], error) {
+ cursor, limit := pageParams(req.Msg.Page)
+ filter := ""
+ if req.Msg.Filter != nil {
+ filter = *req.Msg.Filter
+ }
+
+ workers, total, err := a.srv.db.ListWorkers(ctx, cursor, limit, filter)
+ if err != nil {
+ return nil, mapErr(err)
+ }
+
+ out := make([]*pb.Worker, len(workers))
+ for i := range workers {
+ out[i] = workerToProto(workers[i])
+ }
+
+ var lastID uuid.UUID
+ if len(workers) > 0 {
+ lastID = workers[len(workers)-1].ID
+ }
+
+ return connect.NewResponse(&pb.WorkerListResponse{
+ Page: pageResponse(len(workers), limit, lastID, total),
+ Workers: out,
+ }), nil
+}
+
+func (a *adminService) WorkerDelete(ctx context.Context, req *connect.Request[pb.WorkerDeleteRequest]) (*connect.Response[pb.WorkerDeleteResponse], error) {
+ if err := a.srv.db.DeleteWorker(ctx, uuid.UUID(req.Msg.Id)); err != nil {
+ return nil, mapErr(err)
}
- return connect.NewResponse(&pb.OrgMemberListResponse{Members: pbMembers}), nil
+ return connect.NewResponse(&pb.WorkerDeleteResponse{}), nil
}
diff --git a/go.mod b/go.mod
index 62d7700..889be98 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,13 @@ module dimidiumlabs/mirum
go 1.26.1
require (
+ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1
connectrpc.com/connect v1.19.1
+ connectrpc.com/validate v0.6.0
github.com/coreos/go-systemd/v22 v22.7.0
+ github.com/google/uuid v1.6.0
+ github.com/huandu/go-sqlbuilder v1.40.1
+ github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.9.1
github.com/jackc/tern/v2 v2.3.6
github.com/spf13/cobra v1.8.0
@@ -16,11 +21,15 @@ require (
)
require (
+ buf.build/go/protovalidate v1.1.3 // indirect
+ cel.dev/expr v0.25.1 // indirect
dario.cat/mergo v1.0.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
- github.com/google/uuid v1.6.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+ github.com/google/cel-go v0.27.0 // indirect
+ github.com/huandu/go-clone v1.7.3 // 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
@@ -31,7 +40,10 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
+ golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect
)
diff --git a/go.sum b/go.sum
index 02f3526..931ac40 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,13 @@
+buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ=
+buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
+buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE=
+buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE=
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
+connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4=
+connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@@ -8,6 +16,10 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
+github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
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=
@@ -16,14 +28,25 @@ 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=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
+github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
+github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=
+github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=
+github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ=
+github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE=
+github.com/huandu/go-sqlbuilder v1.40.1 h1:Q2pNM8BAbaezO56ZzkbJNMGW/CTu+8+Qw7IxF4P3+7w=
+github.com/huandu/go-sqlbuilder v1.40.1/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E=
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/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
+github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
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=
@@ -44,6 +67,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
+github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
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=
@@ -57,13 +82,18 @@ 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.starlark.net v0.0.0-20260326113308-fadfc96def35 h1:VYAqieSOJNxBDX8KJneTAwvdf4J4zRDE2u+UFXtt9h4=
go.starlark.net v0.0.0-20260326113308-fadfc96def35/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
+golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -72,11 +102,16 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 h1:jm6v6kMRpTYKxBRrDkYAitNJegUeO1Mf3Kt80obv0gg=
+google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9/go.mod h1:LmwNphe5Afor5V3R5BppOULHOnt2mCIf+NxMd4XiygE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/database/database.go b/internal/database/database.go
index a6cbaed..40940bb 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -12,10 +12,11 @@ import (
)
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")
+ ErrFilterNotImplemented = errors.New("database: filter not implemented")
)
// DB wraps a pgx connection pool.
@@ -78,16 +79,6 @@ func (db *DB) Migrate(ctx context.Context) error {
`DROP TABLE sessions`,
)
- migrator.AppendMigration("create_workers",
- `CREATE TABLE workers (
- id UUID PRIMARY KEY DEFAULT uuidv7(),
- public_key BYTEA NOT NULL UNIQUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- revoked_at TIMESTAMPTZ
- )`,
- `DROP TABLE workers`,
- )
-
migrator.AppendMigration("create_organizations",
`CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuidv7(),
@@ -112,5 +103,16 @@ func (db *DB) Migrate(ctx context.Context) error {
`DROP TABLE org_members`,
)
+ migrator.AppendMigration("create_workers",
+ `CREATE TABLE workers (
+ id UUID PRIMARY KEY DEFAULT uuidv7(),
+ org_id UUID REFERENCES organizations(id),
+ public_key BYTEA NOT NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ revoked_at TIMESTAMPTZ
+ )`,
+ `DROP TABLE workers`,
+ )
+
return migrator.Migrate(ctx)
}
diff --git a/internal/database/organization.go b/internal/database/organization.go
index 53f60e7..90a81bd 100644
--- a/internal/database/organization.go
+++ b/internal/database/organization.go
@@ -6,23 +6,42 @@ package database
import (
"context"
"errors"
- "strings"
"time"
+ "github.com/google/uuid"
+ sb "github.com/huandu/go-sqlbuilder"
+ "github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
)
var (
ErrOrgNotFound = errors.New("database: organization not found")
- ErrAlreadyMember = errors.New("database: user is already a member")
- ErrNotMember = errors.New("database: user is not a member")
- ErrLastOwner = errors.New("database: cannot remove or demote the last owner")
ErrSlugTaken = errors.New("database: slug already taken")
+ ErrAlreadyMember = errors.New("database: already a member")
+ ErrNotMember = errors.New("database: not a member")
+ ErrLastOwner = errors.New("database: last owner")
)
+// OrgRef identifies an organization by ID or slug.
+type OrgRef struct {
+ id uuid.UUID
+ slug string
+}
+
+func OrgByID(id uuid.UUID) OrgRef { return OrgRef{id: id} }
+func OrgBySlug(slug string) OrgRef { return OrgRef{slug: slug} }
+
+func (r OrgRef) where() (string, any) {
+ if r.id != uuid.Nil {
+ return "id", r.id
+ }
+ return "slug", r.slug
+}
+
// Organization holds info about an organization.
type Organization struct {
- ID string
+ ID uuid.UUID
Name string
Slug string
Public bool
@@ -36,105 +55,188 @@ type OrgMember struct {
JoinedAt time.Time
}
+// resolveOrg locks and returns the org ID within a transaction.
+func resolveOrg(ctx context.Context, tx pgx.Tx, ref OrgRef) (uuid.UUID, error) {
+ col, val := ref.where()
+ q := sb.PostgreSQL.NewSelectBuilder()
+
+ sql, args := q.Select("id").From("organizations").
+ Where(q.Equal(col, val), q.IsNull("deleted_at")).
+ ForUpdate().
+ Build()
+
+ var id uuid.UUID
+ if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return uuid.Nil, ErrOrgNotFound
+ }
+
+ return uuid.Nil, err
+ }
+
+ return id, nil
+}
+
+// GetOrg returns an org by ref (ID or slug).
+func (db *DB) GetOrg(ctx context.Context, ref OrgRef) (*Organization, error) {
+ col, val := ref.where()
+ q := sb.PostgreSQL.NewSelectBuilder()
+
+ sql, args := q.Select("id", "name", "slug", "public", "created_at").
+ From("organizations").
+ Where(q.Equal(col, val), q.IsNull("deleted_at")).
+ Build()
+
+ var o Organization
+ if err := db.Pool.QueryRow(ctx, sql, args...).Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrOrgNotFound
+ }
+
+ return nil, err
+ }
+
+ return &o, nil
+}
+
// CreateOrganization creates an org and adds the owner as the first member.
-func (db *DB) CreateOrganization(ctx context.Context, name, slug string, public bool, ownerEmail string) (string, error) {
+func (db *DB) CreateOrganization(ctx context.Context, name, slug string, public bool, owner UserRef) (uuid.UUID, error) {
tx, err := db.Pool.Begin(ctx)
if err != nil {
- return "", err
+ return uuid.Nil, err
}
defer tx.Rollback(ctx)
- var userID string
- if err := tx.QueryRow(ctx,
- `SELECT id FROM users WHERE email = $1 AND deleted_at IS NULL`, ownerEmail,
- ).Scan(&userID); err != nil {
- return "", ErrUserNotFound
+ userID, err := resolveUser(ctx, tx, owner)
+ if err != nil {
+ return uuid.Nil, err
}
- var orgID string
+ var orgID uuid.UUID
if err := tx.QueryRow(ctx,
- `INSERT INTO organizations (name, slug, public) VALUES ($1, $2, $3) RETURNING id`, name, slug, public,
+ `INSERT INTO organizations (name, slug, public) VALUES ($1, $2, $3) RETURNING id`,
+ name, slug, public,
).Scan(&orgID); err != nil {
- return "", err
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
+ return uuid.Nil, ErrSlugTaken
+ }
+
+ return uuid.Nil, err
}
if _, err := tx.Exec(ctx,
- `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, orgID, userID,
+ `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`,
+ orgID, userID,
); err != nil {
- return "", err
+ return uuid.Nil, err
}
return orgID, tx.Commit(ctx)
}
-// DeleteOrganization soft-deletes an org and removes all members.
-func (db *DB) DeleteOrganization(ctx context.Context, slug string) error {
+// UpdateOrganization updates an org's name, slug, and/or public flag.
+func (db *DB) UpdateOrganization(ctx context.Context, ref OrgRef, name *string, slug *string, public *bool) error {
+ if name == nil && slug == nil && public == nil {
+ return nil
+ }
+
tx, err := db.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- var orgID string
- if err := tx.QueryRow(ctx,
- `UPDATE organizations SET slug = id::text, deleted_at = now()
- WHERE slug = $1 AND deleted_at IS NULL RETURNING id`, slug,
- ).Scan(&orgID); err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return ErrOrgNotFound
- }
+ id, err := resolveOrg(ctx, tx, ref)
+ if err != nil {
return err
}
- if _, err := tx.Exec(ctx, `DELETE FROM org_members WHERE org_id = $1`, orgID); err != nil {
+ ub := sb.PostgreSQL.NewUpdateBuilder()
+ ub.Update("organizations")
+ if name != nil {
+ ub.SetMore(ub.Assign("name", *name))
+ }
+ if slug != nil {
+ ub.SetMore(ub.Assign("slug", *slug))
+ }
+ if public != nil {
+ ub.SetMore(ub.Assign("public", *public))
+ }
+ ub.Where(ub.Equal("id", id))
+
+ sql, args := ub.Build()
+ if _, err := tx.Exec(ctx, sql, args...); err != nil {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
+ return ErrSlugTaken
+ }
+
return err
}
return tx.Commit(ctx)
}
-// RenameOrganization updates the name and/or slug.
-func (db *DB) RenameOrganization(ctx context.Context, currentSlug, newName, newSlug string) error {
- tag, err := db.Pool.Exec(ctx,
- `UPDATE organizations SET name = $1, slug = $2
- WHERE slug = $3 AND deleted_at IS NULL`,
- newName, newSlug, currentSlug,
- )
+// DeleteOrganization soft-deletes an org and removes all members.
+func (db *DB) DeleteOrganization(ctx context.Context, ref OrgRef) error {
+ tx, err := db.Pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+
+ id, err := resolveOrg(ctx, tx, ref)
if err != nil {
- if strings.Contains(err.Error(), "unique") {
- return ErrSlugTaken
- }
return err
}
- if tag.RowsAffected() == 0 {
- return ErrOrgNotFound
+
+ if _, err := tx.Exec(ctx, `DELETE FROM org_members WHERE org_id = $1`, id); err != nil {
+ return err
+ }
+
+ if _, err := tx.Exec(ctx,
+ `UPDATE organizations SET slug = id::text, deleted_at = now() WHERE id = $1`, id,
+ ); err != nil {
+ return err
}
- return nil
+
+ return tx.Commit(ctx)
}
-// GetOrganizationBySlug returns an org by its slug.
-func (db *DB) GetOrganizationBySlug(ctx context.Context, slug string) (*Organization, error) {
- var o Organization
- err := db.Pool.QueryRow(ctx,
- `SELECT id, name, slug, public, created_at FROM organizations WHERE slug = $1 AND deleted_at IS NULL`,
- slug,
- ).Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt)
+// ListOrganizations returns a page of orgs and the total count.
+func (db *DB) ListOrganizations(ctx context.Context, cursor uuid.UUID, limit int, filter string) ([]Organization, int, error) {
+ if filter != "" {
+ return nil, 0, ErrFilterNotImplemented
+ }
+
+ tx, err := db.Pool.Begin(ctx)
if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, ErrOrgNotFound
- }
- return nil, err
+ return nil, 0, err
}
- return &o, nil
-}
+ defer tx.Rollback(ctx)
-// ListAllOrganizations returns all active orgs.
-func (db *DB) ListAllOrganizations(ctx context.Context) ([]Organization, error) {
- rows, err := db.Pool.Query(ctx,
- `SELECT id, name, slug, public, created_at FROM organizations WHERE deleted_at IS NULL ORDER BY name`,
- )
+ var total int
+ if err := tx.QueryRow(ctx,
+ `SELECT count(*) FROM organizations WHERE deleted_at IS NULL`,
+ ).Scan(&total); err != nil {
+ return nil, 0, err
+ }
+
+ q := sb.PostgreSQL.NewSelectBuilder()
+ q.Select("id", "name", "slug", "public", "created_at").
+ From("organizations").
+ Where(q.IsNull("deleted_at")).
+ OrderBy("id").
+ Limit(limit)
+ if cursor != uuid.Nil {
+ q.Where(q.GreaterThan("id", cursor))
+ }
+
+ sql, args := q.Build()
+ rows, err := tx.Query(ctx, sql, args...)
if err != nil {
- return nil, err
+ return nil, 0, err
}
defer rows.Close()
@@ -142,53 +244,84 @@ func (db *DB) ListAllOrganizations(ctx context.Context) ([]Organization, error)
for rows.Next() {
var o Organization
if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil {
- return nil, err
+ return nil, 0, err
}
orgs = append(orgs, o)
}
- return orgs, rows.Err()
+ return orgs, total, rows.Err()
}
-// ListUserOrganizations returns all orgs a user belongs to.
-func (db *DB) ListUserOrganizations(ctx context.Context, email string) ([]Organization, error) {
- rows, err := db.Pool.Query(ctx,
- `SELECT o.id, o.name, o.slug, o.public, o.created_at
- FROM organizations o
- JOIN org_members m ON m.org_id = o.id
- JOIN users u ON u.id = m.user_id
- WHERE u.email = $1 AND u.deleted_at IS NULL AND o.deleted_at IS NULL
- ORDER BY o.name`,
- email,
- )
+// GetOrgMember returns a single member's info.
+func (db *DB) GetOrgMember(ctx context.Context, org OrgRef, user UserRef) (*OrgMember, error) {
+ tx, err := db.Pool.Begin(ctx)
if err != nil {
return nil, err
}
- defer rows.Close()
+ defer tx.Rollback(ctx)
- var orgs []Organization
- for rows.Next() {
- var o Organization
- if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil {
- return nil, err
+ orgID, err := resolveOrg(ctx, tx, org)
+ if err != nil {
+ return nil, err
+ }
+ userID, err := resolveUser(ctx, tx, user)
+ if err != nil {
+ return nil, err
+ }
+
+ var m OrgMember
+ if err := tx.QueryRow(ctx,
+ `SELECT u.id, u.email, u.created_at, om.role, om.created_at
+ FROM org_members om
+ JOIN users u ON u.id = om.user_id
+ WHERE om.org_id = $1 AND om.user_id = $2`, orgID, userID,
+ ).Scan(&m.User.ID, &m.User.Email, &m.User.CreatedAt, &m.Role, &m.JoinedAt); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrNotMember
}
- orgs = append(orgs, o)
+ return nil, err
}
- return orgs, rows.Err()
+ return &m, nil
}
-// ListOrgMembers returns all members of an org.
-func (db *DB) ListOrgMembers(ctx context.Context, orgSlug string) ([]OrgMember, error) {
- rows, err := db.Pool.Query(ctx,
- `SELECT u.id, u.email, u.created_at, m.role, m.created_at
- FROM org_members m
- JOIN users u ON u.id = m.user_id
- JOIN organizations o ON o.id = m.org_id
- WHERE o.slug = $1 AND o.deleted_at IS NULL AND u.deleted_at IS NULL
- ORDER BY m.created_at`,
- orgSlug,
- )
+// ListOrgMembers returns a page of members for an org.
+func (db *DB) ListOrgMembers(ctx context.Context, org OrgRef, cursor uuid.UUID, limit int, filter string) ([]OrgMember, int, error) {
+ if filter != "" {
+ return nil, 0, ErrFilterNotImplemented
+ }
+
+ tx, err := db.Pool.Begin(ctx)
if err != nil {
- return nil, err
+ return nil, 0, err
+ }
+ defer tx.Rollback(ctx)
+
+ orgID, err := resolveOrg(ctx, tx, org)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var total int
+ if err := tx.QueryRow(ctx,
+ `SELECT count(*) FROM org_members WHERE org_id = $1`, orgID,
+ ).Scan(&total); err != nil {
+ return nil, 0, err
+ }
+
+ q := sb.PostgreSQL.NewSelectBuilder()
+ q.Select("u.id", "u.email", "u.created_at", "m.role", "m.created_at").
+ From("org_members m").
+ Join("users u", "u.id = m.user_id").
+ Where(q.Equal("m.org_id", orgID), q.IsNull("u.deleted_at")).
+ OrderBy("u.id").
+ Limit(limit)
+ if cursor != uuid.Nil {
+ q.Where(q.GreaterThan("u.id", cursor))
+ }
+
+ sql, args := q.Build()
+ rows, err := tx.Query(ctx, sql, args...)
+ if err != nil {
+ return nil, 0, err
}
defer rows.Close()
@@ -196,61 +329,79 @@ func (db *DB) ListOrgMembers(ctx context.Context, orgSlug string) ([]OrgMember,
for rows.Next() {
var m OrgMember
if err := rows.Scan(&m.User.ID, &m.User.Email, &m.User.CreatedAt, &m.Role, &m.JoinedAt); err != nil {
- return nil, err
+ return nil, 0, err
}
+
members = append(members, m)
}
- return members, rows.Err()
+
+ return members, total, rows.Err()
}
// AddOrgMember adds a user to an org with the given role.
-func (db *DB) AddOrgMember(ctx context.Context, orgSlug, email, role string) error {
- tag, err := db.Pool.Exec(ctx,
- `INSERT INTO org_members (org_id, user_id, role)
- SELECT o.id, u.id, $3
- FROM organizations o, users u
- WHERE o.slug = $1 AND u.email = $2 AND o.deleted_at IS NULL AND u.deleted_at IS NULL`,
- orgSlug, email, role,
- )
+func (db *DB) AddOrgMember(ctx context.Context, org OrgRef, user UserRef, role string) error {
+ tx, err := db.Pool.Begin(ctx)
if err != nil {
- if strings.Contains(err.Error(), "duplicate key") {
+ return err
+ }
+ defer tx.Rollback(ctx)
+
+ orgID, err := resolveOrg(ctx, tx, org)
+ if err != nil {
+ return err
+ }
+ userID, err := resolveUser(ctx, tx, user)
+ if err != nil {
+ return err
+ }
+
+ if _, err := tx.Exec(ctx,
+ `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, $3)`,
+ orgID, userID, role,
+ ); err != nil {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
return ErrAlreadyMember
}
return err
}
- if tag.RowsAffected() == 0 {
- return ErrOrgNotFound
- }
- return nil
+
+ return tx.Commit(ctx)
}
-// RemoveOrgMember removes a user from an org. Fails if they are the last owner.
-func (db *DB) RemoveOrgMember(ctx context.Context, orgSlug, email string) error {
+// UpdateOrgMemberRole changes a member's role. Fails if demoting the last owner.
+func (db *DB) UpdateOrgMemberRole(ctx context.Context, org OrgRef, user UserRef, newRole string) error {
tx, err := db.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- var orgID, userID, role string
+ orgID, err := resolveOrg(ctx, tx, org)
+ if err != nil {
+ return err
+ }
+ userID, err := resolveUser(ctx, tx, user)
+ if err != nil {
+ return err
+ }
+
+ var currentRole string
if err := tx.QueryRow(ctx,
- `SELECT o.id, u.id, m.role
- FROM org_members m
- JOIN organizations o ON o.id = m.org_id
- JOIN users u ON u.id = m.user_id
- WHERE o.slug = $1 AND u.email = $2 AND o.deleted_at IS NULL`,
- orgSlug, email,
- ).Scan(&orgID, &userID, &role); err != nil {
+ `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 FOR UPDATE`,
+ orgID, userID,
+ ).Scan(&currentRole); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotMember
}
return err
}
- if role == "owner" {
+ if currentRole == "owner" && newRole != "owner" {
var ownerCount int
if err := tx.QueryRow(ctx,
- `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, orgID,
+ `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`,
+ orgID,
).Scan(&ownerCount); err != nil {
return err
}
@@ -260,7 +411,8 @@ func (db *DB) RemoveOrgMember(ctx context.Context, orgSlug, email string) error
}
if _, err := tx.Exec(ctx,
- `DELETE FROM org_members WHERE org_id = $1 AND user_id = $2`, orgID, userID,
+ `UPDATE org_members SET role = $1 WHERE org_id = $2 AND user_id = $3`,
+ newRole, orgID, userID,
); err != nil {
return err
}
@@ -268,33 +420,39 @@ func (db *DB) RemoveOrgMember(ctx context.Context, orgSlug, email string) error
return tx.Commit(ctx)
}
-// ChangeOrgMemberRole changes a member's role. Fails if demoting the last owner.
-func (db *DB) ChangeOrgMemberRole(ctx context.Context, orgSlug, email, newRole string) error {
+// RemoveOrgMember removes a user from an org. Fails if they are the last owner.
+func (db *DB) RemoveOrgMember(ctx context.Context, org OrgRef, user UserRef) error {
tx, err := db.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- var orgID, userID, currentRole string
+ orgID, err := resolveOrg(ctx, tx, org)
+ if err != nil {
+ return err
+ }
+ userID, err := resolveUser(ctx, tx, user)
+ if err != nil {
+ return err
+ }
+
+ var role string
if err := tx.QueryRow(ctx,
- `SELECT o.id, u.id, m.role
- FROM org_members m
- JOIN organizations o ON o.id = m.org_id
- JOIN users u ON u.id = m.user_id
- WHERE o.slug = $1 AND u.email = $2 AND o.deleted_at IS NULL`,
- orgSlug, email,
- ).Scan(&orgID, &userID, &currentRole); err != nil {
+ `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 FOR UPDATE`,
+ orgID, userID,
+ ).Scan(&role); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotMember
}
return err
}
- if currentRole == "owner" && newRole != "owner" {
+ if role == "owner" {
var ownerCount int
if err := tx.QueryRow(ctx,
- `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, orgID,
+ `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`,
+ orgID,
).Scan(&ownerCount); err != nil {
return err
}
@@ -304,8 +462,8 @@ func (db *DB) ChangeOrgMemberRole(ctx context.Context, orgSlug, email, newRole s
}
if _, err := tx.Exec(ctx,
- `UPDATE org_members SET role = $1 WHERE org_id = $2 AND user_id = $3`,
- newRole, orgID, userID,
+ `DELETE FROM org_members WHERE org_id = $1 AND user_id = $2`,
+ orgID, userID,
); err != nil {
return err
}
diff --git a/internal/database/user.go b/internal/database/user.go
index 6cf5f6f..78dae2b 100644
--- a/internal/database/user.go
+++ b/internal/database/user.go
@@ -15,19 +15,37 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
+ sb "github.com/huandu/go-sqlbuilder"
+ "github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
"golang.org/x/crypto/argon2"
)
var (
- ErrCreateUser = errors.New("database: failed to create user")
- ErrSetPassword = errors.New("database: failed to set password")
- ErrDeleteUser = errors.New("database: failed to delete user")
- ErrInvalidCreds = errors.New("invalid credentials")
- ErrSoleOwner = errors.New("database: user is the sole owner of an organization")
+ ErrSoleOwner = errors.New("database: sole owner of an organization")
+ ErrEmailTaken = errors.New("database: email already taken")
+ ErrInvalidCreds = errors.New("database: invalid credentials")
ErrUserNotFound = errors.New("database: user not found")
)
+// UserRef identifies a user by ID or email.
+type UserRef struct {
+ id uuid.UUID
+ email string
+}
+
+func UserByID(id uuid.UUID) UserRef { return UserRef{id: id} }
+func UserByEmail(email string) UserRef { return UserRef{email: email} }
+
+func (r UserRef) where() (string, any) {
+ if r.id != uuid.Nil {
+ return "id", r.id
+ }
+ return "email", r.email
+}
+
const (
saltLen = 16
@@ -41,14 +59,14 @@ const (
// User holds info about a user.
type User struct {
- ID string
+ ID uuid.UUID
Email string
CreatedAt time.Time
}
// Session holds info about an authenticated session.
type Session struct {
- UserID string
+ UserID uuid.UUID
Email string
}
@@ -116,159 +134,289 @@ func hashPassword(password string, pepper []byte) (string, error) {
// UserCreate 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) UserCreate(ctx context.Context, email, password string, pepper []byte) (string, error) {
+func (db *DB) UserCreate(ctx context.Context, email, password string, pepper []byte) (uuid.UUID, error) {
hash, err := hashPassword(password, pepper)
if err != nil {
- return "", err
+ return uuid.Nil, err
}
- var id string
- err = db.Pool.QueryRow(ctx,
+ var id uuid.UUID
+ if 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)
+ ).Scan(&id); err != nil {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
+ return uuid.Nil, ErrEmailTaken
+ }
+ return uuid.Nil, err
}
return id, nil
}
-// UserDelete clears all fields but keeps the row to preserve the id.
-// Also deletes all sessions for that user in a single transaction.
-func (db *DB) UserDelete(ctx context.Context, email string) error {
+// GetUser returns a user by ref (ID or email).
+func (db *DB) GetUser(ctx context.Context, ref UserRef) (*User, error) {
+ col, val := ref.where()
+
+ q := sb.PostgreSQL.NewSelectBuilder()
+ sql, args := q.Select("id", "email", "created_at").
+ From("users").
+ Where(q.Equal(col, val), q.IsNull("deleted_at")).
+ Build()
+
+ var u User
+ if err := db.Pool.QueryRow(ctx, sql, args...).Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrUserNotFound
+ }
+
+ return nil, err
+ }
+
+ return &u, nil
+}
+
+// ListUsers returns a page of users and the total count.
+func (db *DB) ListUsers(ctx context.Context, cursor uuid.UUID, limit int, filter string) ([]User, int, error) {
+ if filter != "" {
+ return nil, 0, ErrFilterNotImplemented
+ }
+
tx, err := db.Pool.Begin(ctx)
if err != nil {
- return errors.Join(ErrDeleteUser, err)
+ return nil, 0, err
}
defer tx.Rollback(ctx)
- var id string
- err = tx.QueryRow(ctx,
- `UPDATE users SET email = id::text, password = '', deleted_at = now() WHERE email = $1 RETURNING id`,
- email,
- ).Scan(&id)
+ var total int
+ if err := tx.QueryRow(ctx,
+ `SELECT count(*) FROM users WHERE deleted_at IS NULL`,
+ ).Scan(&total); err != nil {
+ return nil, 0, err
+ }
+
+ q := sb.PostgreSQL.NewSelectBuilder()
+ q.Select("id", "email", "created_at").
+ From("users").
+ Where(q.IsNull("deleted_at")).
+ OrderBy("id").
+ Limit(limit)
+ if cursor != uuid.Nil {
+ q.Where(q.GreaterThan("id", cursor))
+ }
+
+ sql, args := q.Build()
+ rows, err := tx.Query(ctx, sql, args...)
if err != nil {
- return errors.Join(ErrDeleteUser, err)
+ return nil, 0, err
+ }
+ defer rows.Close()
+
+ var users []User
+ for rows.Next() {
+ var u User
+ if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
+ return nil, 0, err
+ }
+ users = append(users, u)
}
- var soloOwnedSlug string
- err = tx.QueryRow(ctx,
+ return users, total, rows.Err()
+}
+
+// checkNotSoleOwner returns ErrSoleOwner if the user is the only owner of any org.
+func checkNotSoleOwner(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error {
+ var slug string
+ err := tx.QueryRow(ctx,
`SELECT o.slug FROM org_members m
JOIN organizations o ON o.id = m.org_id
WHERE m.role = 'owner' AND o.deleted_at IS NULL
GROUP BY o.id, o.slug
HAVING count(*) = 1 AND bool_or(m.user_id = $1)
- LIMIT 1`, id,
- ).Scan(&soloOwnedSlug)
+ LIMIT 1`, userID,
+ ).Scan(&slug)
if err == nil {
return ErrSoleOwner
}
- if !errors.Is(err, pgx.ErrNoRows) {
- return errors.Join(ErrDeleteUser, err)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
}
+ return err
+}
- if _, err = tx.Exec(ctx, `DELETE FROM org_members WHERE user_id = $1`, id); err != nil {
- return errors.Join(ErrDeleteUser, err)
- }
+// resolveUser locks and returns the user ID within a transaction.
+func resolveUser(ctx context.Context, tx pgx.Tx, ref UserRef) (uuid.UUID, error) {
+ col, val := ref.where()
+ q := sb.PostgreSQL.NewSelectBuilder()
- if _, err = tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil {
- return errors.Join(ErrDeleteUser, err)
+ sql, args := q.Select("id").From("users").
+ Where(q.Equal(col, val), q.IsNull("deleted_at")).
+ ForUpdate().
+ Build()
+
+ var id uuid.UUID
+ if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return uuid.Nil, ErrUserNotFound
+ }
+
+ return uuid.Nil, err
}
- return tx.Commit(ctx)
+ return id, nil
}
-// UserSetPassword updates the password and invalidates all existing sessions.
-func (db *DB) UserSetPassword(ctx context.Context, email, password string, pepper []byte) error {
- hash, err := hashPassword(password, pepper)
+// UserUpdate updates a user's email and/or password.
+// Invalidates all sessions when password changes.
+func (db *DB) UserUpdate(ctx context.Context, ref UserRef, email *string, password *string, pepper []byte) error {
+ if email == nil && password == nil {
+ return nil
+ }
+
+ tx, err := db.Pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+
+ id, err := resolveUser(ctx, tx, ref)
if err != nil {
return err
}
+ ub := sb.PostgreSQL.NewUpdateBuilder()
+ ub.Update("users")
+
+ if email != nil {
+ ub.SetMore(ub.Assign("email", *email))
+ }
+
+ if password != nil {
+ hash, err := hashPassword(*password, pepper)
+ if err != nil {
+ return err
+ }
+
+ ub.SetMore(ub.Assign("password", hash))
+ }
+
+ ub.Where(ub.Equal("id", id))
+
+ sql, args := ub.Build()
+ if _, err := tx.Exec(ctx, sql, args...); err != nil {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
+ return ErrEmailTaken
+ }
+ return err
+ }
+
+ if password != nil {
+ if _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit(ctx)
+}
+
+// UserDelete soft-deletes a user.
+// Fails if the user is the sole owner of any organization.
+func (db *DB) UserDelete(ctx context.Context, ref UserRef) error {
tx, err := db.Pool.Begin(ctx)
if err != nil {
- return errors.Join(ErrSetPassword, err)
+ return err
}
defer tx.Rollback(ctx)
- var id string
- err = tx.QueryRow(ctx,
- `UPDATE users SET password = $1 WHERE email = $2 AND deleted_at IS NULL RETURNING id`,
- hash, email,
- ).Scan(&id)
+ id, err := resolveUser(ctx, tx, ref)
if err != nil {
- return errors.Join(ErrSetPassword, err)
+ return err
}
- _, err = tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id)
- if err != nil {
- return errors.Join(ErrSetPassword, err)
+ if err := checkNotSoleOwner(ctx, tx, id); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(ctx, `DELETE FROM org_members WHERE user_id = $1`, id); err != nil {
+ return err
+ }
+
+ if _, err := tx.Exec(ctx,
+ `UPDATE users SET email = id::text, password = '', deleted_at = now() WHERE id = $1`, id,
+ ); err != nil {
+ return err
}
return tx.Commit(ctx)
}
// UserVerifyPassword checks credentials and returns the user ID.
-func (db *DB) UserVerifyPassword(ctx context.Context, email, password string, pepper []byte) (string, error) {
- var id, hash string
- err := db.Pool.QueryRow(ctx,
+func (db *DB) UserVerifyPassword(ctx context.Context, email, password string, pepper []byte) (uuid.UUID, error) {
+ var id uuid.UUID
+ var hash string
+
+ if err := db.Pool.QueryRow(ctx,
`SELECT id, password FROM users WHERE email = $1 AND deleted_at IS NULL`,
email,
- ).Scan(&id, &hash)
- if err != nil {
- return "", ErrInvalidCreds
+ ).Scan(&id, &hash); err != nil {
+ return uuid.Nil, ErrInvalidCreds
}
if !verifyHash(password, hash, pepper) {
- return "", ErrInvalidCreds
+ return uuid.Nil, ErrInvalidCreds
}
return id, nil
}
// UserGetSession returns session info for a valid, non-expired session.
-// It extends the session expiry only when less than half the TTL remains,
-// avoiding a write on every request.
func (db *DB) UserGetSession(ctx context.Context, token string) (*Session, error) {
- h := hashToken(token)
var s Session
+ h := hashToken(token)
var expiresAt time.Time
- err := db.Pool.QueryRow(ctx,
+
+ if err := db.Pool.QueryRow(ctx,
`SELECT s.user_id, u.email, s.expires_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = $1 AND s.expires_at > now() AND u.deleted_at IS NULL`,
h,
- ).Scan(&s.UserID, &s.Email, &expiresAt)
- if err != nil {
+ ).Scan(&s.UserID, &s.Email, &expiresAt); err != nil {
return nil, err
}
if time.Until(expiresAt) < SessionTTL/2 {
- db.Pool.Exec(ctx,
+ if _, err := db.Pool.Exec(ctx,
`UPDATE sessions SET expires_at = now() + $2 WHERE token = $1`,
h, SessionTTL,
- )
+ ); err != nil {
+ return nil, err
+ }
}
return &s, nil
}
// UserCreateSession generates a random token, stores its hash, and returns the token.
-func (db *DB) UserCreateSession(ctx context.Context, userID string) (string, error) {
- b := make([]byte, 32)
- if _, err := rand.Read(b); err != nil {
+func (db *DB) UserCreateSession(ctx context.Context, userID uuid.UUID) (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
return "", err
}
- token := base64.RawURLEncoding.EncodeToString(b)
+ token := base64.RawURLEncoding.EncodeToString(buf)
- _, err := db.Pool.Exec(ctx,
+ if _, err := db.Pool.Exec(ctx,
`INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, now() + $3)`,
hashToken(token), userID, SessionTTL,
- )
- if err != nil {
+ ); err != nil {
return "", err
}
+
return token, nil
}
diff --git a/internal/database/validate.go b/internal/database/validate.go
new file mode 100644
--- /dev/null
+++ b/internal/database/validate.go
@@ -0,0 +1,60 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package database
+
+import (
+ "errors"
+ "net/mail"
+ "regexp"
+ "strings"
+)
+
+var slugRe = regexp.MustCompile(`^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$`)
+
+var validRoles = map[string]bool{
+ "owner": true,
+ "admin": true,
+ "member": true,
+}
+
+var (
+ ErrInvalidEmail = errors.New("database: invalid email")
+ ErrInvalidSlug = errors.New("database: invalid slug")
+ ErrInvalidRole = errors.New("database: invalid role")
+)
+
+// ValidateEmail checks that the value is a valid email address.
+func ValidateEmail(value string) error {
+ if _, err := mail.ParseAddress(value); err != nil {
+ return ErrInvalidEmail
+ }
+ return nil
+}
+
+// ValidateSlug checks format and returns the normalized (lowercased) slug.
+func ValidateSlug(value string) (string, error) {
+ if len(value) < 2 || len(value) > 64 || !slugRe.MatchString(value) {
+ return "", ErrInvalidSlug
+ }
+ return strings.ToLower(value), nil
+}
+
+// ValidateRole checks that the value is a valid role string.
+func ValidateRole(value string) error {
+ if !validRoles[value] {
+ return ErrInvalidRole
+ }
+ return nil
+}
+
+// ClampPageSize clamps a page_size to [1, max]. If v is 0 (unset), returns defaultSize.
+func ClampPageSize(v int32, defaultSize, max int32) int32 {
+ if v <= 0 {
+ return defaultSize
+ }
+ if v > max {
+ return max
+ }
+ return v
+}
diff --git a/internal/database/worker.go b/internal/database/worker.go
index bdd4b0b..c0ff46b 100644
--- a/internal/database/worker.go
+++ b/internal/database/worker.go
@@ -8,52 +8,138 @@ import (
"errors"
"time"
+ "github.com/google/uuid"
+ sb "github.com/huandu/go-sqlbuilder"
"github.com/jackc/pgx/v5"
)
var (
- ErrAddWorker = errors.New("database: failed to add worker")
- ErrRevokeWorker = errors.New("database: failed to revoke worker")
ErrWorkerNotFound = errors.New("database: worker not found")
)
// Worker holds info about a registered worker.
type Worker struct {
- ID string
+ ID uuid.UUID
+ OrgID *uuid.UUID
PublicKey []byte
CreatedAt time.Time
}
-// ListWorkers returns all active (non-revoked) workers.
-func (db *DB) ListWorkers(ctx context.Context) ([]Worker, error) {
- rows, err := db.Pool.Query(ctx,
- `SELECT id, public_key, created_at FROM workers WHERE revoked_at IS NULL ORDER BY created_at`,
+// GetWorker returns a worker by ID.
+func (db *DB) GetWorker(ctx context.Context, id uuid.UUID) (*Worker, error) {
+ var w Worker
+ if err := db.Pool.QueryRow(ctx,
+ `SELECT id, public_key, org_id, created_at FROM workers WHERE id = $1 AND revoked_at IS NULL`,
+ id,
+ ).Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrWorkerNotFound
+ }
+
+ return nil, err
+ }
+
+ return &w, nil
+}
+
+// CreateWorker registers a new worker with the given public key and optional org.
+func (db *DB) CreateWorker(ctx context.Context, publicKey []byte, org *OrgRef) (uuid.UUID, error) {
+ tx, err := db.Pool.Begin(ctx)
+ if err != nil {
+ return uuid.Nil, err
+ }
+ defer tx.Rollback(ctx)
+
+ var orgID *uuid.UUID
+ if org != nil {
+ id, err := resolveOrg(ctx, tx, *org)
+ if err != nil {
+ return uuid.Nil, err
+ }
+ orgID = &id
+ }
+
+ var workerID uuid.UUID
+ if err := tx.QueryRow(ctx,
+ `INSERT INTO workers (public_key, org_id) VALUES ($1, $2) RETURNING id`,
+ publicKey, orgID,
+ ).Scan(&workerID); err != nil {
+ return uuid.Nil, err
+ }
+
+ return workerID, tx.Commit(ctx)
+}
+
+// DeleteWorker soft-deletes a worker by ID.
+func (db *DB) DeleteWorker(ctx context.Context, id uuid.UUID) error {
+ tag, err := db.Pool.Exec(ctx,
+ `UPDATE workers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id,
)
if err != nil {
- return nil, err
+ return err
+ }
+
+ if tag.RowsAffected() == 0 {
+ return ErrWorkerNotFound
+ }
+
+ return nil
+}
+
+// ListWorkers returns a page of workers and the total count.
+func (db *DB) ListWorkers(ctx context.Context, cursor uuid.UUID, limit int, filter string) ([]Worker, int, error) {
+ if filter != "" {
+ return nil, 0, ErrFilterNotImplemented
+ }
+
+ tx, err := db.Pool.Begin(ctx)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer tx.Rollback(ctx)
+
+ var total int
+ if err := tx.QueryRow(ctx,
+ `SELECT count(*) FROM workers WHERE revoked_at IS NULL`,
+ ).Scan(&total); err != nil {
+ return nil, 0, err
+ }
+
+ q := sb.PostgreSQL.NewSelectBuilder()
+ q.Select("id", "public_key", "org_id", "created_at").
+ From("workers").
+ Where(q.IsNull("revoked_at")).
+ OrderBy("id").
+ Limit(limit)
+ if cursor != uuid.Nil {
+ q.Where(q.GreaterThan("id", cursor))
+ }
+
+ sql, args := q.Build()
+ rows, err := tx.Query(ctx, sql, args...)
+ if err != nil {
+ return nil, 0, err
}
defer rows.Close()
var workers []Worker
for rows.Next() {
var w Worker
- if err := rows.Scan(&w.ID, &w.PublicKey, &w.CreatedAt); err != nil {
- return nil, err
+ if err := rows.Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil {
+ return nil, 0, err
}
workers = append(workers, w)
}
-
- return workers, rows.Err()
+ return workers, total, rows.Err()
}
// LookupWorker finds an active worker by its ed25519 public key.
func (db *DB) LookupWorker(ctx context.Context, publicKey []byte) (*Worker, error) {
var w Worker
- err := db.Pool.QueryRow(ctx,
- `SELECT id, public_key, created_at FROM workers WHERE public_key = $1 AND revoked_at IS NULL`,
+ if err := db.Pool.QueryRow(ctx,
+ `SELECT id, public_key, org_id, created_at FROM workers WHERE public_key = $1 AND revoked_at IS NULL`,
publicKey,
- ).Scan(&w.ID, &w.PublicKey, &w.CreatedAt)
- if err != nil {
+ ).Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrWorkerNotFound
}
@@ -61,31 +147,3 @@ func (db *DB) LookupWorker(ctx context.Context, publicKey []byte) (*Worker, erro
}
return &w, nil
}
-
-// AddWorker registers a new worker with the given public key.
-func (db *DB) AddWorker(ctx context.Context, publicKey []byte) (string, error) {
- var id string
- err := db.Pool.QueryRow(ctx,
- `INSERT INTO workers (public_key) VALUES ($1) RETURNING id`,
- publicKey,
- ).Scan(&id)
- if err != nil {
- return "", errors.Join(ErrAddWorker, err)
- }
- return id, nil
-}
-
-// WorkerRevoke soft-deletes a worker by ID.
-func (db *DB) WorkerRevoke(ctx context.Context, id string) error {
- tag, err := db.Pool.Exec(ctx,
- `UPDATE workers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`,
- id,
- )
- if err != nil {
- return errors.Join(ErrRevokeWorker, err)
- }
- if tag.RowsAffected() == 0 {
- return ErrWorkerNotFound
- }
- return nil
-}
diff --git a/proto/admin.proto b/proto/admin.proto
index a8f4b74..e3edb21 100644
--- a/proto/admin.proto
+++ b/proto/admin.proto
@@ -5,157 +5,273 @@ syntax = "proto3";
package mirum;
-option go_package = "dimidiumlabs/mirum/internal/protocol/pb";
-
+import "buf/validate/validate.proto";
import "google/protobuf/timestamp.proto";
+option go_package = "dimidiumlabs/mirum/internal/protocol/pb";
+
service Admin {
rpc UserCreate(UserCreateRequest) returns (UserCreateResponse);
+ rpc UserGet(UserGetRequest) returns (UserGetResponse);
+ rpc UserList(UserListRequest) returns (UserListResponse);
+ rpc UserUpdate(UserUpdateRequest) returns (UserUpdateResponse);
rpc UserDelete(UserDeleteRequest) returns (UserDeleteResponse);
- rpc UserSetPassword(UserSetPasswordRequest) returns (UserSetPasswordResponse);
- rpc OrgList(OrgListRequest) returns (OrgListResponse);
rpc OrgCreate(OrgCreateRequest) returns (OrgCreateResponse);
+ rpc OrgGet(OrgGetRequest) returns (OrgGetResponse);
+ rpc OrgList(OrgListRequest) returns (OrgListResponse);
+ rpc OrgUpdate(OrgUpdateRequest) returns (OrgUpdateResponse);
rpc OrgDelete(OrgDeleteRequest) returns (OrgDeleteResponse);
- rpc OrgRename(OrgRenameRequest) returns (OrgRenameResponse);
- rpc OrgMemberList(OrgMemberListRequest) returns (OrgMemberListResponse);
rpc OrgMemberAdd(OrgMemberAddRequest) returns (OrgMemberAddResponse);
+ rpc OrgMemberGet(OrgMemberGetRequest) returns (OrgMemberGetResponse);
+ rpc OrgMemberList(OrgMemberListRequest) returns (OrgMemberListResponse);
+ rpc OrgMemberUpdate(OrgMemberUpdateRequest) returns (OrgMemberUpdateResponse);
rpc OrgMemberRemove(OrgMemberRemoveRequest) returns (OrgMemberRemoveResponse);
- rpc OrgMemberSetRole(OrgMemberSetRoleRequest) returns (OrgMemberSetRoleResponse);
+ rpc WorkerCreate(WorkerCreateRequest) returns (WorkerCreateResponse);
+ rpc WorkerGet(WorkerGetRequest) returns (WorkerGetResponse);
rpc WorkerList(WorkerListRequest) returns (WorkerListResponse);
- rpc WorkerAdd(WorkerAddRequest) returns (WorkerAddResponse);
- rpc WorkerRevoke(WorkerRevokeRequest) returns (WorkerRevokeResponse);
+ rpc WorkerDelete(WorkerDeleteRequest) returns (WorkerDeleteResponse);
}
-message UserCreateRequest {
- string email = 1;
- string password = 2;
-}
+// Common types
-message UserCreateResponse {
- string id = 1;
+enum Role {
+ ROLE_NONE = 0;
+ ROLE_OWNER = 1;
+ ROLE_ADMIN = 2;
+ ROLE_MEMBER = 3;
}
-message UserSetPasswordRequest {
- string email = 1;
- string password = 2;
+message UserRef {
+ oneof ref {
+ option (buf.validate.oneof).required = true;
+ bytes id = 1 [(buf.validate.field).bytes = {
+ min_len: 16
+ max_len: 16
+ }];
+ string email = 2 [(buf.validate.field).string.email = true];
+ }
}
-message UserSetPasswordResponse {}
-
-message UserDeleteRequest {
- string email = 1;
+message OrgRef {
+ oneof ref {
+ option (buf.validate.oneof).required = true;
+ bytes id = 1 [(buf.validate.field).bytes = {
+ min_len: 16
+ max_len: 16
+ }];
+ string slug = 2 [(buf.validate.field).string = {
+ min_len: 2
+ max_len: 64
+ }];
+ }
}
-message UserDeleteResponse {}
+message PageRequest {
+ bytes cursor = 1;
+ int32 page_size = 2;
+}
+message PageResponse {
+ bytes next_cursor = 1;
+ int32 total_count = 2;
+}
-// Organization management
+// User management
-message OrgCreateRequest {
- string name = 1;
- string slug = 2;
- bool public = 3;
- string owner_email = 4;
+message User {
+ bytes id = 1;
+ string email = 2;
+ google.protobuf.Timestamp created_at = 3;
}
-message OrgCreateResponse {
- string id = 1;
+message UserCreateRequest {
+ string email = 1 [(buf.validate.field).string.email = true];
+ string password = 2 [(buf.validate.field).string.min_len = 1];
}
-
-message OrgDeleteRequest {
- string slug = 1;
+message UserCreateResponse {
+ bytes id = 1;
}
-message OrgDeleteResponse {}
-
-message OrgRenameRequest {
- string slug = 1;
- string new_name = 2;
- string new_slug = 3;
+message UserGetRequest {
+ UserRef user = 1 [(buf.validate.field).required = true];
+}
+message UserGetResponse {
+ User user = 1;
}
-message OrgRenameResponse {}
+message UserListRequest {
+ optional PageRequest page = 1;
+ optional string filter = 2;
+}
+message UserListResponse {
+ PageResponse page = 1;
+ repeated User users = 2;
+}
-message OrgListRequest {
- optional string user_email = 1;
+message UserUpdateRequest {
+ UserRef user = 1 [(buf.validate.field).required = true];
+ optional string email = 2 [(buf.validate.field).string.email = true];
+ optional string password = 3 [(buf.validate.field).string.min_len = 1];
}
+message UserUpdateResponse {}
-message OrgListResponse {
- repeated Org organizations = 1;
+message UserDeleteRequest {
+ UserRef user = 1 [(buf.validate.field).required = true];
}
+message UserDeleteResponse {}
+
+// Organization management
message Org {
- string id = 1;
+ bytes id = 1;
string name = 2;
string slug = 3;
bool public = 4;
google.protobuf.Timestamp created_at = 5;
}
-// Organization members
+message OrgCreateRequest {
+ string name = 1 [(buf.validate.field).string.min_len = 1];
+ string slug = 2 [(buf.validate.field).string = {
+ min_len: 2
+ max_len: 64
+ }];
+ UserRef owner = 3 [(buf.validate.field).required = true];
+ bool public = 4;
+}
+message OrgCreateResponse {
+ bytes id = 1;
+}
-message OrgMemberAddRequest {
- string org_slug = 1;
- string email = 2;
- string role = 3;
+message OrgGetRequest {
+ OrgRef org = 1 [(buf.validate.field).required = true];
+}
+message OrgGetResponse {
+ Org org = 1;
}
-message OrgMemberAddResponse {}
+message OrgListRequest {
+ optional PageRequest page = 1;
+ optional string filter = 2;
+}
+message OrgListResponse {
+ PageResponse page = 1;
+ repeated Org organizations = 2;
+}
-message OrgMemberRemoveRequest {
- string org_slug = 1;
- string email = 2;
+message OrgUpdateRequest {
+ OrgRef org = 1 [(buf.validate.field).required = true];
+ optional string name = 2 [(buf.validate.field).string.min_len = 1];
+ optional string slug = 3 [(buf.validate.field).string = {
+ min_len: 2
+ max_len: 64
+ }];
+ optional bool public = 4;
}
+message OrgUpdateResponse {}
-message OrgMemberRemoveResponse {}
+message OrgDeleteRequest {
+ OrgRef org = 1 [(buf.validate.field).required = true];
+}
+message OrgDeleteResponse {}
-message OrgMemberSetRoleRequest {
- string org_slug = 1;
- string email = 2;
- string role = 3;
+// Organization members
+
+message OrgMemberInfo {
+ User user = 1;
+ Role role = 2;
+ google.protobuf.Timestamp joined_at = 3;
+}
+
+message OrgMemberGetRequest {
+ OrgRef org = 1 [(buf.validate.field).required = true];
+ UserRef user = 2 [(buf.validate.field).required = true];
+}
+message OrgMemberGetResponse {
+ OrgMemberInfo member = 1;
}
-message OrgMemberSetRoleResponse {}
+message OrgMemberAddRequest {
+ OrgRef org = 1 [(buf.validate.field).required = true];
+ UserRef user = 2 [(buf.validate.field).required = true];
+ Role role = 3 [(buf.validate.field).enum = {
+ defined_only: true
+ not_in: [0]
+ }];
+}
+message OrgMemberAddResponse {}
message OrgMemberListRequest {
- string org_slug = 1;
+ optional PageRequest page = 1;
+ OrgRef org = 2 [(buf.validate.field).required = true];
+ optional string filter = 3;
}
-
message OrgMemberListResponse {
- repeated OrgMemberInfo members = 1;
+ PageResponse page = 1;
+ repeated OrgMemberInfo members = 2;
}
-message OrgMemberInfo {
- string email = 1;
- string role = 2;
- google.protobuf.Timestamp joined_at = 3;
+message OrgMemberUpdateRequest {
+ OrgRef org = 1 [(buf.validate.field).required = true];
+ UserRef user = 2 [(buf.validate.field).required = true];
+ Role role = 3 [(buf.validate.field).enum = {
+ defined_only: true
+ not_in: [0]
+ }];
}
+message OrgMemberUpdateResponse {}
-message WorkerAddRequest {
- bytes public_key = 1;
+message OrgMemberRemoveRequest {
+ OrgRef org = 1 [(buf.validate.field).required = true];
+ UserRef user = 2 [(buf.validate.field).required = true];
}
+message OrgMemberRemoveResponse {}
-message WorkerAddResponse {
- string id = 1;
-}
+// Worker management
-message WorkerRevokeRequest {
- string id = 1;
+message Worker {
+ bytes id = 1;
+ bytes public_key = 2;
+ optional bytes org_id = 3;
+ google.protobuf.Timestamp created_at = 4;
}
-message WorkerRevokeResponse {}
+message WorkerCreateRequest {
+ bytes public_key = 1 [(buf.validate.field).bytes = {
+ min_len: 32
+ max_len: 32
+ }];
+ optional OrgRef org = 2;
+}
+message WorkerCreateResponse {
+ bytes id = 1;
+}
-message WorkerListRequest {}
+message WorkerGetRequest {
+ bytes id = 1 [(buf.validate.field).bytes = {
+ min_len: 16
+ max_len: 16
+ }];
+}
+message WorkerGetResponse {
+ Worker worker = 1;
+}
+message WorkerListRequest {
+ optional PageRequest page = 1;
+ optional string filter = 2;
+}
message WorkerListResponse {
- repeated Worker workers = 1;
+ PageResponse page = 1;
+ repeated Worker workers = 2;
}
-message Worker {
- string id = 1;
- bytes public_key = 2;
- google.protobuf.Timestamp created_at = 3;
+message WorkerDeleteRequest {
+ bytes id = 1 [(buf.validate.field).bytes = {
+ min_len: 16
+ max_len: 16
+ }];
}
-
+message WorkerDeleteResponse {}
diff --git a/proto/buf.lock b/proto/buf.lock
new file mode 100644
--- /dev/null
+++ b/proto/buf.lock
@@ -0,0 +1,6 @@
+# Generated by buf. DO NOT EDIT.
+version: v2
+deps:
+ - name: buf.build/bufbuild/protovalidate
+ commit: 80ab13bee0bf4272b6161a72bf7034e0
+ digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1
diff --git a/proto/buf.yaml b/proto/buf.yaml
new file mode 100644
--- /dev/null
+++ b/proto/buf.yaml
@@ -0,0 +1,6 @@
+# Copyright (c) 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+version: v2
+deps:
+ - buf.build/bufbuild/protovalidate