diff options
Diffstat (limited to 'cmd/mirum-server/server_admin.go')
| -rw-r--r-- | cmd/mirum-server/server_admin.go | 556 | +0 −556 |
1 files changed, 0 insertions, 556 deletions
diff --git a/cmd/mirum-server/server_admin.go b/cmd/mirum-server/server_admin.go deleted file mode 100644 --- a/cmd/mirum-server/server_admin.go +++ /dev/null @@ -1,556 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "errors" - "log/slog" - "net/http" - - "connectrpc.com/connect" - "connectrpc.com/validate" - "google.golang.org/protobuf/types/known/timestamppb" - - "dimidiumlabs/mirum/cmd/mirum-server/apipb" - "dimidiumlabs/mirum/cmd/mirum-server/apipb/apipbconnect" -) - -// NewConsoleHandler creates the ConnectRPC handler with validation. -// Authorization is handled inside DB methods, not by an interceptor. -func NewConsoleHandler(srv *server) (string, http.Handler) { - as := &consoleService{srv: srv} - return apipbconnect.NewConsoleHandler(as, - connect.WithInterceptors(validate.NewInterceptor()), - ) -} - -type consoleService struct { - apipbconnect.UnimplementedConsoleHandler - srv *server -} - -// --- Error mapping --- - -// newAPIError builds a ConnectError with an empty message string and attaches -// an ErrorInfo detail carrying the domain reason. Clients switch on reason to -// pick user-facing text; the wire never carries human-readable strings. -func newAPIError(code connect.Code, reason apipb.ErrorReason, metadata map[string]string) error { - e := connect.NewError(code, nil) - if d, err := connect.NewErrorDetail(&apipb.ErrorInfo{Reason: reason, Metadata: metadata}); err == nil { - e.AddDetail(d) - } - return e -} - -var errSpecs = []struct { - err error - code connect.Code - reason apipb.ErrorReason -}{ - {ErrUserNotFound, connect.CodeNotFound, apipb.ErrorReason_ERROR_REASON_USER_NOT_FOUND}, - {ErrOrgNotFound, connect.CodeNotFound, apipb.ErrorReason_ERROR_REASON_ORG_NOT_FOUND}, - {ErrWorkerNotFound, connect.CodeNotFound, apipb.ErrorReason_ERROR_REASON_WORKER_NOT_FOUND}, - {ErrNotMember, connect.CodeNotFound, apipb.ErrorReason_ERROR_REASON_MEMBER_NOT_FOUND}, - {ErrEmailTaken, connect.CodeAlreadyExists, apipb.ErrorReason_ERROR_REASON_EMAIL_TAKEN}, - {ErrSlugTaken, connect.CodeAlreadyExists, apipb.ErrorReason_ERROR_REASON_SLUG_TAKEN}, - {ErrAlreadyMember, connect.CodeAlreadyExists, apipb.ErrorReason_ERROR_REASON_ALREADY_MEMBER}, - {ErrLastOwner, connect.CodeFailedPrecondition, apipb.ErrorReason_ERROR_REASON_LAST_OWNER}, - {ErrSoleOwner, connect.CodeFailedPrecondition, apipb.ErrorReason_ERROR_REASON_SOLE_OWNER}, - {ErrInvalidSlug, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_SLUG}, - {ErrInvalidRole, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_ROLE}, - {ErrInvalidDateFormat, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_DATE_FORMAT}, - {ErrInvalidTimezone, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_TIMEZONE}, - {ErrReservedEmail, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_RESERVED_EMAIL}, - {ErrPermissionDenied, connect.CodePermissionDenied, apipb.ErrorReason_ERROR_REASON_PERMISSION_DENIED}, - {ErrUnauthenticated, connect.CodeUnauthenticated, apipb.ErrorReason_ERROR_REASON_UNAUTHENTICATED}, - {ErrNotImplemented, connect.CodeUnimplemented, apipb.ErrorReason_ERROR_REASON_UNIMPLEMENTED}, -} - -func mapErr(err error) error { - if err == nil { - return nil - } - for _, s := range errSpecs { - if errors.Is(err, s.err) { - return newAPIError(s.code, s.reason, nil) - } - } - slog.Error("unmapped handler error", "err", err) - return newAPIError(connect.CodeInternal, apipb.ErrorReason_ERROR_REASON_INTERNAL, nil) -} - -// --- Ref converters --- - -func userRef(r *apipb.UserRef) (UserRef, error) { - switch v := r.GetRef().(type) { - case *apipb.UserRef_Id: - id, err := IDFromBytes[UserKind](v.Id) - if err != nil { - return UserRef{}, err - } - return UserByID(id), nil - case *apipb.UserRef_Email: - return UserByEmail(v.Email), nil - default: - return UserRef{}, nil - } -} - -func orgRef(r *apipb.OrgRef) (OrgRef, error) { - switch v := r.GetRef().(type) { - case *apipb.OrgRef_Id: - id, err := IDFromBytes[OrgKind](v.Id) - if err != nil { - return OrgRef{}, err - } - return OrgByID(id), nil - case *apipb.OrgRef_Slug: - return OrgBySlug(v.Slug), nil - default: - return OrgRef{}, nil - } -} - -// --- Role converters --- - -var roleToString = map[apipb.Role]string{ - apipb.Role_ROLE_OWNER: "owner", - apipb.Role_ROLE_ADMIN: "admin", - apipb.Role_ROLE_MEMBER: "member", -} - -var roleToProto = map[string]apipb.Role{ - "owner": apipb.Role_ROLE_OWNER, - "admin": apipb.Role_ROLE_ADMIN, - "member": apipb.Role_ROLE_MEMBER, -} - -// --- Page helpers --- - -const ( - defaultPageSize = 50 - maxPageSize = 200 -) - -func pageParams[K IDKind](p *apipb.PageRequest) (cursor ID[K], limit int, err error) { - 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, err = IDFromBytes[K](p.Cursor) - if err != nil { - return - } - } - } - return -} - -func pageResponse[K IDKind](items int, limit int, lastID ID[K], total int) *apipb.PageResponse { - resp := &apipb.PageResponse{TotalCount: int32(total)} - if items == limit { - resp.NextCursor = lastID.Bytes() - } - return resp -} - -// --- Proto converters --- - -func userToProto(u User) *apipb.User { - pb := &apipb.User{ - Id: u.ID.Bytes(), Email: u.Email, CreatedAt: timestamppb.New(u.CreatedAt), - Timezone: u.Timezone, - } - pb.Locale = &apipb.Locale{Language: u.Locale.Language} - if u.Locale.DateFormat != nil { - df := apipb.DateFormat(*u.Locale.DateFormat) - pb.Locale.DateFormat = &df - } - return pb -} - -func orgToProto(o Organization) *apipb.Org { - return &apipb.Org{ - Id: o.ID.Bytes(), Name: o.Name, Slug: o.Slug, - Public: o.Public, CreatedAt: timestamppb.New(o.CreatedAt), - } -} - -func memberToProto(m OrgMember) *apipb.OrgMemberInfo { - return &apipb.OrgMemberInfo{ - User: userToProto(m.User), Role: roleToProto[m.Role], - JoinedAt: timestamppb.New(m.JoinedAt), - } -} - -func workerToProto(w Worker) *apipb.Worker { - pw := &apipb.Worker{ - Id: w.ID.Bytes(), PublicKey: w.PublicKey, CreatedAt: timestamppb.New(w.CreatedAt), - } - if w.OrgID != nil { - pw.OrgId = w.OrgID.Bytes() - } - return pw -} - -// --- User handlers --- - -func (a *consoleService) UserCreate(ctx context.Context, req *connect.Request[apipb.UserCreateRequest]) (*connect.Response[apipb.UserCreateResponse], error) { - id, err := a.srv.db.UserCreate(ctx, ActorFromContext(ctx), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.UserCreateResponse{Id: id.Bytes()}), nil -} - -func (a *consoleService) UserGet(ctx context.Context, req *connect.Request[apipb.UserGetRequest]) (*connect.Response[apipb.UserGetResponse], error) { - ref, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - u, err := a.srv.db.UserGet(ctx, ActorFromContext(ctx), ref) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.UserGetResponse{User: userToProto(*u)}), nil -} - -func (a *consoleService) UserList(ctx context.Context, req *connect.Request[apipb.UserListRequest]) (*connect.Response[apipb.UserListResponse], error) { - cursor, limit, err := pageParams[UserKind](req.Msg.Page) - if err != nil { - return nil, mapErr(err) - } - filter := "" - if req.Msg.Filter != nil { - filter = *req.Msg.Filter - } - - users, total, err := a.srv.db.UserList(ctx, ActorFromContext(ctx), cursor, limit, filter) - if err != nil { - return nil, mapErr(err) - } - - out := make([]*apipb.User, len(users)) - for i := range users { - out[i] = userToProto(users[i]) - } - - var lastID UserID - if len(users) > 0 { - lastID = users[len(users)-1].ID - } - - return connect.NewResponse(&apipb.UserListResponse{ - Page: pageResponse(len(users), limit, lastID, total), - Users: out, - }), nil -} - -func (a *consoleService) UserUpdate(ctx context.Context, req *connect.Request[apipb.UserUpdateRequest]) (*connect.Response[apipb.UserUpdateResponse], error) { - ref, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - p := UserUpdateParams{ - Email: req.Msg.Email, - Password: req.Msg.Password, - Timezone: req.Msg.Timezone, - } - if req.Msg.Locale != nil { - p.Locale = &Locale{Language: req.Msg.Locale.Language} - if req.Msg.Locale.DateFormat != nil { - df := DateFormat(*req.Msg.Locale.DateFormat) - p.Locale.DateFormat = &df - } - } - if err := a.srv.db.UserUpdate(ctx, ActorFromContext(ctx), ref, p, []byte(a.srv.cfg.Pepper)); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.UserUpdateResponse{}), nil -} - -func (a *consoleService) UserDelete(ctx context.Context, req *connect.Request[apipb.UserDeleteRequest]) (*connect.Response[apipb.UserDeleteResponse], error) { - ref, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.UserDelete(ctx, ActorFromContext(ctx), ref); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.UserDeleteResponse{}), nil -} - -// --- Org handlers --- - -func (a *consoleService) OrgCreate(ctx context.Context, req *connect.Request[apipb.OrgCreateRequest]) (*connect.Response[apipb.OrgCreateResponse], error) { - slug, err := ValidateSlug(req.Msg.Slug) - if err != nil { - return nil, mapErr(err) - } - owner, err := userRef(req.Msg.Owner) - if err != nil { - return nil, mapErr(err) - } - id, err := a.srv.db.OrgCreate(ctx, ActorFromContext(ctx), req.Msg.Name, slug, req.Msg.Public, owner) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.OrgCreateResponse{Id: id.Bytes()}), nil -} - -func (a *consoleService) OrgGet(ctx context.Context, req *connect.Request[apipb.OrgGetRequest]) (*connect.Response[apipb.OrgGetResponse], error) { - ref, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - o, err := a.srv.db.OrgGet(ctx, ActorFromContext(ctx), ref) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.OrgGetResponse{Org: orgToProto(*o)}), nil -} - -func (a *consoleService) OrgList(ctx context.Context, req *connect.Request[apipb.OrgListRequest]) (*connect.Response[apipb.OrgListResponse], error) { - cursor, limit, err := pageParams[OrgKind](req.Msg.Page) - if err != nil { - return nil, mapErr(err) - } - filter := "" - if req.Msg.Filter != nil { - filter = *req.Msg.Filter - } - - orgs, total, err := a.srv.db.OrgList(ctx, ActorFromContext(ctx), cursor, limit, filter) - if err != nil { - return nil, mapErr(err) - } - - out := make([]*apipb.Org, len(orgs)) - for i := range orgs { - out[i] = orgToProto(orgs[i]) - } - - var lastID OrgID - if len(orgs) > 0 { - lastID = orgs[len(orgs)-1].ID - } - - return connect.NewResponse(&apipb.OrgListResponse{ - Page: pageResponse(len(orgs), limit, lastID, total), - Organizations: out, - }), nil -} - -func (a *consoleService) OrgUpdate(ctx context.Context, req *connect.Request[apipb.OrgUpdateRequest]) (*connect.Response[apipb.OrgUpdateResponse], error) { - var slug *string - if req.Msg.Slug != nil { - s, err := ValidateSlug(*req.Msg.Slug) - if err != nil { - return nil, mapErr(err) - } - slug = &s - } - ref, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgUpdate(ctx, ActorFromContext(ctx), ref, req.Msg.Name, slug, req.Msg.Public); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.OrgUpdateResponse{}), nil -} - -func (a *consoleService) OrgDelete(ctx context.Context, req *connect.Request[apipb.OrgDeleteRequest]) (*connect.Response[apipb.OrgDeleteResponse], error) { - ref, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgDelete(ctx, ActorFromContext(ctx), ref); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.OrgDeleteResponse{}), nil -} - -// --- OrgMember handlers --- - -func (a *consoleService) OrgMemberAdd(ctx context.Context, req *connect.Request[apipb.OrgMemberAddRequest]) (*connect.Response[apipb.OrgMemberAddResponse], error) { - role, ok := roleToString[req.Msg.Role] - if !ok { - return nil, newAPIError(connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil) - } - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - user, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgMemberAdd(ctx, ActorFromContext(ctx), org, user, role); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.OrgMemberAddResponse{}), nil -} - -func (a *consoleService) OrgMemberGet(ctx context.Context, req *connect.Request[apipb.OrgMemberGetRequest]) (*connect.Response[apipb.OrgMemberGetResponse], error) { - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - user, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - m, err := a.srv.db.OrgMemberGet(ctx, ActorFromContext(ctx), org, user) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.OrgMemberGetResponse{Member: memberToProto(*m)}), nil -} - -func (a *consoleService) OrgMemberList(ctx context.Context, req *connect.Request[apipb.OrgMemberListRequest]) (*connect.Response[apipb.OrgMemberListResponse], error) { - cursor, limit, err := pageParams[UserKind](req.Msg.Page) - if err != nil { - return nil, mapErr(err) - } - filter := "" - if req.Msg.Filter != nil { - filter = *req.Msg.Filter - } - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - - members, total, err := a.srv.db.OrgMembersList(ctx, ActorFromContext(ctx), org, cursor, limit, filter) - if err != nil { - return nil, mapErr(err) - } - - out := make([]*apipb.OrgMemberInfo, len(members)) - for i := range members { - out[i] = memberToProto(members[i]) - } - - var lastID UserID - if len(members) > 0 { - lastID = members[len(members)-1].User.ID - } - - return connect.NewResponse(&apipb.OrgMemberListResponse{ - Page: pageResponse(len(members), limit, lastID, total), - Members: out, - }), nil -} - -func (a *consoleService) OrgMemberUpdate(ctx context.Context, req *connect.Request[apipb.OrgMemberUpdateRequest]) (*connect.Response[apipb.OrgMemberUpdateResponse], error) { - role, ok := roleToString[req.Msg.Role] - if !ok { - return nil, newAPIError(connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil) - } - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - user, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgMemberUpdateRole(ctx, ActorFromContext(ctx), org, user, role); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.OrgMemberUpdateResponse{}), nil -} - -func (a *consoleService) OrgMemberRemove(ctx context.Context, req *connect.Request[apipb.OrgMemberRemoveRequest]) (*connect.Response[apipb.OrgMemberRemoveResponse], error) { - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - user, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgMemberRemove(ctx, ActorFromContext(ctx), org, user); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.OrgMemberRemoveResponse{}), nil -} - -// --- Worker handlers --- - -func (a *consoleService) WorkerCreate(ctx context.Context, req *connect.Request[apipb.WorkerCreateRequest]) (*connect.Response[apipb.WorkerCreateResponse], error) { - var org *OrgRef - if req.Msg.Org != nil { - r, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - org = &r - } - id, err := a.srv.db.WorkerCreate(ctx, ActorFromContext(ctx), req.Msg.PublicKey, org) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.WorkerCreateResponse{Id: id.Bytes()}), nil -} - -func (a *consoleService) WorkerGet(ctx context.Context, req *connect.Request[apipb.WorkerGetRequest]) (*connect.Response[apipb.WorkerGetResponse], error) { - wid, err := IDFromBytes[WorkerKind](req.Msg.Id) - if err != nil { - return nil, mapErr(err) - } - w, err := a.srv.db.WorkerGet(ctx, ActorFromContext(ctx), wid) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.WorkerGetResponse{Worker: workerToProto(*w)}), nil -} - -func (a *consoleService) WorkerList(ctx context.Context, req *connect.Request[apipb.WorkerListRequest]) (*connect.Response[apipb.WorkerListResponse], error) { - cursor, limit, err := pageParams[WorkerKind](req.Msg.Page) - if err != nil { - return nil, mapErr(err) - } - filter := "" - if req.Msg.Filter != nil { - filter = *req.Msg.Filter - } - - workers, total, err := a.srv.db.WorkerList(ctx, ActorFromContext(ctx), cursor, limit, filter) - if err != nil { - return nil, mapErr(err) - } - - out := make([]*apipb.Worker, len(workers)) - for i := range workers { - out[i] = workerToProto(workers[i]) - } - - var lastID WorkerID - if len(workers) > 0 { - lastID = workers[len(workers)-1].ID - } - - return connect.NewResponse(&apipb.WorkerListResponse{ - Page: pageResponse(len(workers), limit, lastID, total), - Workers: out, - }), nil -} - -func (a *consoleService) WorkerDelete(ctx context.Context, req *connect.Request[apipb.WorkerDeleteRequest]) (*connect.Response[apipb.WorkerDeleteResponse], error) { - wid, err := IDFromBytes[WorkerKind](req.Msg.Id) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.WorkerDelete(ctx, ActorFromContext(ctx), wid); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&apipb.WorkerDeleteResponse{}), nil -} |
