aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--cmd/mirumd/api_auth.go47+25 −22
-rw-r--r--cmd/mirumd/api_cli.go427+427 −0
-rw-r--r--cmd/mirumd/cert.go67+67 −0
-rw-r--r--cmd/mirumd/cert_test.go142+142 −0
-rw-r--r--cmd/mirumd/config.go6+3 −3
-rw-r--r--cmd/mirumd/main.go588+87 −501
-rw-r--r--cmd/mirumd/server.go8+6 −2
-rw-r--r--cmd/mirumd/server_admin.go112+58 −54
-rw-r--r--cmd/mirumd/server_grpc.go20+10 −10
-rw-r--r--cmd/mirumd/server_web.go144+89 −55
-rw-r--r--cmd/mirumd/static.go5+4 −1
-rw-r--r--cmd/mirumd/web/api/client.ts29+23 −6
-rw-r--r--cmd/mirumd/web/components/pages/dashboard.tsx16+11 −5
-rw-r--r--cmd/mirumd/web/components/pages/error.tsx47+47 −0
-rw-r--r--cmd/mirumd/web/components/pages/login.tsx10+7 −3
-rw-r--r--cmd/mirumd/web/entries/error.tsx8+8 −0
-rw-r--r--cmd/mirumd/web/lib/errors.ts84+84 −0
-rw-r--r--cmd/mirumd/web/shell.html5+5 −0
-rw-r--r--internal/config/config.go54+54 −0
-rw-r--r--internal/database/actor.go117+117 −0
-rw-r--r--internal/database/database.go40+26 −14
-rw-r--r--internal/database/organization.go20+10 −10
-rw-r--r--internal/database/user.go83+48 −35
-rw-r--r--internal/database/worker.go10+5 −5
-rw-r--r--internal/protocol/backoff.go4+3 −1
-rw-r--r--internal/protocol/handshake.go4+3 −1
-rw-r--r--proto/admin.proto47+47 −0
27 files changed, 1416 insertions, 728 deletions
diff --git a/cmd/mirumd/api_auth.go b/cmd/mirumd/api_auth.go
index d278d3c..38abde4 100644
--- a/cmd/mirumd/api_auth.go
+++ b/cmd/mirumd/api_auth.go
@@ -49,11 +49,11 @@ func (a *ApiAuthInterceptor) authorize(ctx context.Context, procedure string, re
return nil
}
- caller := CallerFromContext(ctx)
- if caller == nil {
- return connect.NewError(connect.CodeUnauthenticated, nil)
+ actor := ActorFromContext(ctx)
+ if actor.Kind() == database.KindAnon {
+ return errUnauthenticated
}
- if caller.Superuser {
+ if actor.IsSuperuser() {
return nil
}
@@ -66,7 +66,7 @@ func (a *ApiAuthInterceptor) authorize(ctx context.Context, procedure string, re
return errDenied
}
- if isSelf(*caller, req) {
+ if isSelf(actor, req) {
return nil
}
return errDenied
@@ -80,18 +80,18 @@ func (a *ApiAuthInterceptor) authorize(ctx context.Context, procedure string, re
case pbconnect.AdminOrgCreateProcedure:
return nil
case pbconnect.AdminOrgUpdateProcedure:
- return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_ORG_WRITE)
+ return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_ORG_WRITE)
case pbconnect.AdminOrgDeleteProcedure:
- return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_ORG_DELETE)
+ return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_ORG_DELETE)
// OrgMember — org-scoped
case pbconnect.AdminOrgMemberGetProcedure,
pbconnect.AdminOrgMemberListProcedure:
- return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_ORG_MEMBER_READ)
+ return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_ORG_MEMBER_READ)
case pbconnect.AdminOrgMemberAddProcedure,
pbconnect.AdminOrgMemberUpdateProcedure,
pbconnect.AdminOrgMemberRemoveProcedure:
- return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_ORG_MEMBER_WRITE)
+ return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_ORG_MEMBER_WRITE)
// Worker — any authenticated can read
case pbconnect.AdminWorkerGetProcedure,
@@ -103,26 +103,29 @@ func (a *ApiAuthInterceptor) authorize(ctx context.Context, procedure string, re
if orgRefFromRequest(req) == nil {
return errDenied
}
- return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_WORKER_WRITE)
+ return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_WORKER_WRITE)
// Worker delete — lookup worker's org, then check
case pbconnect.AdminWorkerDeleteProcedure:
- return a.checkWorkerDelete(ctx, *caller, req)
+ return a.checkWorkerDelete(ctx, actor, req)
default:
return errDenied
}
}
-var errDenied = connect.NewError(connect.CodePermissionDenied, nil)
+var (
+ errDenied = newAPIError(connect.CodePermissionDenied, pb.ErrorReason_ERROR_REASON_PERMISSION_DENIED, nil)
+ errUnauthenticated = newAPIError(connect.CodeUnauthenticated, pb.ErrorReason_ERROR_REASON_UNAUTHENTICATED, nil)
+)
-// checkOrgPerm extracts OrgRef from request and checks caller's role permission.
-func (a *ApiAuthInterceptor) checkOrgPerm(ctx context.Context, caller callerInfo, req connect.AnyRequest, perm pb.Perm) error {
+// checkOrgPerm extracts OrgRef from request and checks the actor's role permission.
+func (a *ApiAuthInterceptor) checkOrgPerm(ctx context.Context, actor database.Actor, req connect.AnyRequest, perm pb.Perm) error {
ref := orgRefFromRequest(req)
if ref == nil {
return errDenied
}
- member, err := a.srv.db.GetOrgMember(ctx, caller.UserID, orgRef(ref), database.UserByID(caller.UserID))
+ member, err := a.srv.db.GetOrgMember(ctx, actor, orgRef(ref), database.UserByID(actor.UserID()))
if err != nil {
return errDenied
}
@@ -133,19 +136,19 @@ func (a *ApiAuthInterceptor) checkOrgPerm(ctx context.Context, caller callerInfo
}
// checkWorkerDelete looks up the worker's org and checks permission.
-func (a *ApiAuthInterceptor) checkWorkerDelete(ctx context.Context, caller callerInfo, req connect.AnyRequest) error {
+func (a *ApiAuthInterceptor) checkWorkerDelete(ctx context.Context, actor database.Actor, req connect.AnyRequest) error {
m, ok := req.Any().(*pb.WorkerDeleteRequest)
if !ok {
return errDenied
}
- w, err := a.srv.db.GetWorker(ctx, caller.UserID, uuid.UUID(m.Id))
+ w, err := a.srv.db.GetWorker(ctx, actor, uuid.UUID(m.Id))
if err != nil {
return errDenied
}
if w.OrgID == nil {
return errDenied // global worker — superuser only
}
- member, err := a.srv.db.GetOrgMember(ctx, caller.UserID, database.OrgByID(*w.OrgID), database.UserByID(caller.UserID))
+ member, err := a.srv.db.GetOrgMember(ctx, actor, database.OrgByID(*w.OrgID), database.UserByID(actor.UserID()))
if err != nil {
return errDenied
}
@@ -155,8 +158,8 @@ func (a *ApiAuthInterceptor) checkWorkerDelete(ctx context.Context, caller calle
return nil
}
-// isSelf checks if the request targets the caller's own user.
-func isSelf(caller callerInfo, req connect.AnyRequest) bool {
+// isSelf checks if the request targets the actor's own user.
+func isSelf(actor database.Actor, req connect.AnyRequest) bool {
var ref *pb.UserRef
switch m := req.Any().(type) {
case *pb.UserGetRequest:
@@ -171,9 +174,9 @@ func isSelf(caller callerInfo, req connect.AnyRequest) bool {
}
switch v := ref.GetRef().(type) {
case *pb.UserRef_Id:
- return uuid.UUID(v.Id) == caller.UserID
+ return uuid.UUID(v.Id) == actor.UserID()
case *pb.UserRef_Email:
- return v.Email == caller.Email
+ return v.Email == actor.Email()
default:
return false
}
diff --git a/cmd/mirumd/api_cli.go b/cmd/mirumd/api_cli.go
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/api_cli.go
@@ -0,0 +1,427 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+// Admin CLI is generated from admin.proto at startup via protoreflect.
+// RPC name is camelCase-split into a cobra path: UserCreate -> "user create",
+// OrgMemberAdd -> "org member add". Flags come from request fields, dispatch
+// goes through reflect on pbconnect.AdminClient.
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/x509"
+ "encoding/base64"
+ "fmt"
+ "os"
+ "reflect"
+ "strings"
+ "unicode"
+
+ "github.com/google/uuid"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/reflect/protoreflect"
+ "google.golang.org/protobuf/reflect/protoregistry"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "dimidiumlabs/mirum/internal/protocol/pb"
+ "dimidiumlabs/mirum/internal/protocol/pb/pbconnect"
+)
+
+// mkClient is called per-invocation so persistent flags (e.g. --socket) are
+// already parsed by the time it runs.
+func buildAdminCLI(root *cobra.Command, mkClient func() pbconnect.AdminClient) {
+ methods := pb.File_admin_proto.Services().ByName("Admin").Methods()
+ for i := 0; i < methods.Len(); i++ {
+ md := methods.Get(i)
+ path := splitCamel(string(md.Name()))
+ parent := ensureGroups(root, path[:len(path)-1])
+ parent.AddCommand(buildMethodCmd(md, path[len(path)-1], mkClient))
+ }
+}
+
+// "OrgMemberAdd" -> ["org","member","add"].
+func splitCamel(s string) []string {
+ var parts []string
+ start := 0
+ for i := 1; i < len(s); i++ {
+ if unicode.IsUpper(rune(s[i])) {
+ parts = append(parts, strings.ToLower(s[start:i]))
+ start = i
+ }
+ }
+ return append(parts, strings.ToLower(s[start:]))
+}
+
+func ensureGroups(root *cobra.Command, path []string) *cobra.Command {
+ parent := root
+ for _, name := range path {
+ var next *cobra.Command
+ for _, c := range parent.Commands() {
+ if c.Name() == name {
+ next = c
+ break
+ }
+ }
+ if next == nil {
+ next = &cobra.Command{Use: name, Short: "Manage " + name}
+ parent.AddCommand(next)
+ }
+ parent = next
+ }
+ return parent
+}
+
+// fieldSetter writes one flag value into the request message.
+type fieldSetter func(*pflag.FlagSet, protoreflect.Message) error
+
+func buildMethodCmd(md protoreflect.MethodDescriptor, leaf string, mkClient func() pbconnect.AdminClient) *cobra.Command {
+ reqDesc := md.Input()
+ rpcName := string(md.Name())
+ cmd := &cobra.Command{
+ Use: leaf,
+ Short: rpcName,
+ }
+ setters := registerRequestFlags(cmd, reqDesc)
+ cmd.Run = func(c *cobra.Command, _ []string) {
+ req, err := buildRequest(reqDesc, c.Flags(), setters)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ resp, err := dispatchAdmin(mkClient(), rpcName, req)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ printResponse(resp)
+ }
+ return cmd
+}
+
+func registerRequestFlags(cmd *cobra.Command, desc protoreflect.MessageDescriptor) []fieldSetter {
+ var setters []fieldSetter
+ fields := desc.Fields()
+ for i := 0; i < fields.Len(); i++ {
+ if s := registerField(cmd, fields.Get(i)); s != nil {
+ setters = append(setters, s)
+ }
+ }
+ return setters
+}
+
+func registerField(cmd *cobra.Command, fd protoreflect.FieldDescriptor) fieldSetter {
+ flagName := strings.ReplaceAll(string(fd.Name()), "_", "-")
+ // Required iff non-optional in the schema; bools default to false, so
+ // marking them required makes no sense.
+ required := !fd.HasOptionalKeyword() && fd.Kind() != protoreflect.BoolKind
+ flags := cmd.Flags()
+
+ markRequired := func() {
+ if required {
+ _ = cmd.MarkFlagRequired(flagName)
+ }
+ }
+
+ switch fd.Kind() {
+ case protoreflect.StringKind:
+ flags.String(flagName, "", string(fd.Name()))
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ m.Set(fd, protoreflect.ValueOfString(v))
+ return nil
+ }
+
+ case protoreflect.BoolKind:
+ flags.Bool(flagName, false, string(fd.Name()))
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ // Preserve "unset" vs "false" for optional bools.
+ if fd.HasOptionalKeyword() && !fs.Changed(flagName) {
+ return nil
+ }
+ v, _ := fs.GetBool(flagName)
+ m.Set(fd, protoreflect.ValueOfBool(v))
+ return nil
+ }
+
+ case protoreflect.BytesKind:
+ flags.String(flagName, "", string(fd.Name()))
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ b, err := parseBytesFlag(string(fd.Name()), v)
+ if err != nil {
+ return fmt.Errorf("--%s: %w", flagName, err)
+ }
+ m.Set(fd, protoreflect.ValueOfBytes(b))
+ return nil
+ }
+
+ case protoreflect.EnumKind:
+ flags.String(flagName, "", string(fd.Name()))
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ n, err := parseEnumFlag(fd.Enum(), v)
+ if err != nil {
+ return fmt.Errorf("--%s: %w", flagName, err)
+ }
+ m.Set(fd, protoreflect.ValueOfEnum(n))
+ return nil
+ }
+
+ case protoreflect.MessageKind:
+ return registerMessageField(cmd, fd, flagName, required)
+ }
+
+ if required {
+ panic(fmt.Sprintf("admincli: unhandled required field %s (kind=%s)", fd.FullName(), fd.Kind()))
+ }
+ return nil
+}
+
+// Only UserRef/OrgRef are flattened (to the string arm of their oneof);
+// PageRequest is skipped; anything else required panics at startup so
+// schema changes can't silently send malformed requests.
+func registerMessageField(cmd *cobra.Command, fd protoreflect.FieldDescriptor, flagName string, required bool) fieldSetter {
+ flags := cmd.Flags()
+ markRequired := func() {
+ if required {
+ _ = cmd.MarkFlagRequired(flagName)
+ }
+ }
+
+ switch fd.Message().FullName() {
+ case "mirum.UserRef":
+ flags.String(flagName, "", "user email")
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ ref := &pb.UserRef{Ref: &pb.UserRef_Email{Email: v}}
+ m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect()))
+ return nil
+ }
+
+ case "mirum.OrgRef":
+ flags.String(flagName, "", "org slug")
+ markRequired()
+ return func(fs *pflag.FlagSet, m protoreflect.Message) error {
+ v, _ := fs.GetString(flagName)
+ if v == "" {
+ return nil
+ }
+ ref := &pb.OrgRef{Ref: &pb.OrgRef_Slug{Slug: v}}
+ m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect()))
+ return nil
+ }
+
+ case "mirum.PageRequest":
+ return nil
+ }
+
+ if required {
+ panic(fmt.Sprintf("admincli: unhandled required message field %s (type=%s)", fd.FullName(), fd.Message().FullName()))
+ }
+ return nil
+}
+
+// Admin schema uses bytes only for UUIDs (id / *_id) and ed25519 PKIX keys.
+func parseBytesFlag(fieldName, v string) ([]byte, error) {
+ switch {
+ case fieldName == "id" || strings.HasSuffix(fieldName, "_id"):
+ u, err := uuid.Parse(v)
+ if err != nil {
+ return nil, fmt.Errorf("invalid uuid: %w", err)
+ }
+ return u[:], nil
+ case strings.Contains(fieldName, "key"):
+ der, err := base64.StdEncoding.DecodeString(v)
+ if err != nil {
+ return nil, fmt.Errorf("invalid base64: %w", err)
+ }
+ pub, err := x509.ParsePKIXPublicKey(der)
+ if err != nil {
+ return nil, fmt.Errorf("invalid public key: %w", err)
+ }
+ ed, ok := pub.(ed25519.PublicKey)
+ if !ok {
+ return nil, fmt.Errorf("not an ed25519 key")
+ }
+ return ed, nil
+ }
+ return nil, fmt.Errorf("unsupported bytes field %q", fieldName)
+}
+
+// Accepts both short ("admin") and full ("ROLE_ADMIN") forms.
+func parseEnumFlag(ed protoreflect.EnumDescriptor, v string) (protoreflect.EnumNumber, error) {
+ want := strings.ToUpper(v)
+ values := ed.Values()
+ if ev := values.ByName(protoreflect.Name(want)); ev != nil {
+ return ev.Number(), nil
+ }
+ prefix := strings.ToUpper(string(ed.Name())) + "_"
+ if ev := values.ByName(protoreflect.Name(prefix + want)); ev != nil {
+ return ev.Number(), nil
+ }
+ return 0, fmt.Errorf("unknown %s value %q", ed.Name(), v)
+}
+
+func buildRequest(desc protoreflect.MessageDescriptor, flags *pflag.FlagSet, setters []fieldSetter) (proto.Message, error) {
+ mt, err := protoregistry.GlobalTypes.FindMessageByName(desc.FullName())
+ if err != nil {
+ return nil, fmt.Errorf("find message %s: %w", desc.FullName(), err)
+ }
+ m := mt.New()
+ for _, set := range setters {
+ if err := set(flags, m); err != nil {
+ return nil, err
+ }
+ }
+ return m.Interface(), nil
+}
+
+// reflect.New on *connect.Request[T] is equivalent to connect.NewRequest(req):
+// Msg is the only public field, the rest are initialised lazily at send time.
+func dispatchAdmin(client pbconnect.AdminClient, name string, req proto.Message) (proto.Message, error) {
+ cv := reflect.ValueOf(client)
+ method := cv.MethodByName(name)
+ if !method.IsValid() {
+ return nil, fmt.Errorf("unknown admin method %q", name)
+ }
+ // method signature:
+ // func(context.Context, *connect.Request[T]) (*connect.Response[U], error)
+ reqPtrType := method.Type().In(1) // *connect.Request[T]
+ reqWrap := reflect.New(reqPtrType.Elem())
+ reqWrap.Elem().FieldByName("Msg").Set(reflect.ValueOf(req))
+
+ out := method.Call([]reflect.Value{
+ reflect.ValueOf(context.Background()),
+ reqWrap,
+ })
+ if errV := out[1]; !errV.IsNil() {
+ return nil, errV.Interface().(error)
+ }
+ return out[0].Elem().FieldByName("Msg").Interface().(proto.Message), nil
+}
+
+// Shape-driven printer: empty → "ok", single bytes id → UUID, single message
+// → TSV of scalars, repeated → one TSV line per element, anything else → TSV
+// of top-level scalars. PageResponse metadata is ignored.
+func printResponse(resp proto.Message) {
+ m := resp.ProtoReflect()
+ fields := m.Descriptor().Fields()
+
+ var meaningful []protoreflect.FieldDescriptor
+ for i := 0; i < fields.Len(); i++ {
+ f := fields.Get(i)
+ if f.Kind() == protoreflect.MessageKind && f.Message().FullName() == "mirum.PageResponse" {
+ continue
+ }
+ meaningful = append(meaningful, f)
+ }
+
+ if len(meaningful) == 0 {
+ fmt.Println("ok")
+ return
+ }
+ if len(meaningful) == 1 {
+ f := meaningful[0]
+ v := m.Get(f)
+ switch {
+ case f.IsList():
+ list := v.List()
+ for j := 0; j < list.Len(); j++ {
+ item := list.Get(j)
+ if f.Kind() == protoreflect.MessageKind {
+ fmt.Println(formatMessageTSV(item.Message()))
+ } else {
+ fmt.Println(formatScalar(f, item))
+ }
+ }
+ case f.Kind() == protoreflect.MessageKind:
+ fmt.Println(formatMessageTSV(v.Message()))
+ default:
+ fmt.Println(formatScalar(f, v))
+ }
+ return
+ }
+ fmt.Println(formatMessageTSV(m))
+}
+
+func formatMessageTSV(m protoreflect.Message) string {
+ var parts []string
+ fields := m.Descriptor().Fields()
+ for i := 0; i < fields.Len(); i++ {
+ f := fields.Get(i)
+ if f.IsList() || f.IsMap() {
+ continue
+ }
+ if f.HasOptionalKeyword() && !m.Has(f) {
+ continue
+ }
+ if f.Kind() == protoreflect.MessageKind {
+ if f.Message().FullName() == "google.protobuf.Timestamp" {
+ ts := m.Get(f).Message().Interface().(*timestamppb.Timestamp)
+ parts = append(parts, ts.AsTime().Format("2006-01-02"))
+ continue
+ }
+ nested := m.Get(f).Message()
+ if nested.IsValid() {
+ parts = append(parts, formatMessageTSV(nested))
+ }
+ continue
+ }
+ parts = append(parts, formatScalar(f, m.Get(f)))
+ }
+ return strings.Join(parts, "\t")
+}
+
+func formatScalar(fd protoreflect.FieldDescriptor, v protoreflect.Value) string {
+ switch fd.Kind() {
+ case protoreflect.StringKind:
+ return v.String()
+ case protoreflect.BoolKind:
+ if v.Bool() {
+ return "true"
+ }
+ return "false"
+ case protoreflect.BytesKind:
+ return formatBytes(v.Bytes())
+ case protoreflect.EnumKind:
+ ev := fd.Enum().Values().ByNumber(v.Enum())
+ if ev == nil {
+ return fmt.Sprintf("%d", v.Enum())
+ }
+ name := string(ev.Name())
+ if idx := strings.IndexByte(name, '_'); idx >= 0 {
+ name = name[idx+1:]
+ }
+ return strings.ToLower(name)
+ }
+ return v.String()
+}
+
+func formatBytes(b []byte) string {
+ if len(b) == 16 {
+ if u, err := uuid.FromBytes(b); err == nil {
+ return u.String()
+ }
+ }
+ return base64.StdEncoding.EncodeToString(b)
+}
diff --git a/cmd/mirumd/cert.go b/cmd/mirumd/cert.go
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/cert.go
@@ -0,0 +1,67 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+import (
+ "crypto/tls"
+ "log/slog"
+ "os"
+ "sync"
+ "time"
+)
+
+// certReloader serves a TLS cert/key pair and reloads it when either file
+// changes on disk (e.g. after a letsencrypt renewal).
+type certReloader struct {
+ certFile string
+ keyFile string
+
+ mu sync.Mutex
+ cert *tls.Certificate
+ certMod time.Time
+ keyMod time.Time
+}
+
+func newCertReloader(certFile, keyFile string) *certReloader {
+ r := &certReloader{certFile: certFile, keyFile: keyFile}
+ if _, err := r.GetCertificate(nil); err != nil {
+ slog.Warn("initial cert load failed", "cert", certFile, "err", err)
+ }
+ return r
+}
+
+// GetCertificate plugs into tls.Config.GetCertificate. On a transient
+// reload error it returns the last good pair so a mid-renewal race
+// (cert swapped but key still being written) doesn't break handshakes.
+func (r *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+ cs, cerr := os.Stat(r.certFile)
+ ks, kerr := os.Stat(r.keyFile)
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ if cerr == nil && kerr == nil && r.cert != nil &&
+ cs.ModTime().Equal(r.certMod) && ks.ModTime().Equal(r.keyMod) {
+ return r.cert, nil
+ }
+
+ cert, err := tls.LoadX509KeyPair(r.certFile, r.keyFile)
+ if err != nil {
+ if r.cert != nil {
+ slog.Warn("cert reload failed, serving cached", "cert", r.certFile, "err", err)
+ return r.cert, nil
+ }
+ return nil, err
+ }
+
+ r.cert = &cert
+ if cerr == nil {
+ r.certMod = cs.ModTime()
+ }
+ if kerr == nil {
+ r.keyMod = ks.ModTime()
+ }
+ slog.Info("cert loaded", "cert", r.certFile)
+ return r.cert, nil
+}
diff --git a/cmd/mirumd/cert_test.go b/cmd/mirumd/cert_test.go
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/cert_test.go
@@ -0,0 +1,142 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/pem"
+ "math/big"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func writeTestPair(t *testing.T, certPath, keyPath string) {
+ t.Helper()
+
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
+ SerialNumber: serial,
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(time.Hour),
+ }, &x509.Certificate{SerialNumber: serial}, pub, priv)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(certPath,
+ pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
+ 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(keyPath,
+ pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
+ 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCertReloader_Cached(t *testing.T) {
+ dir := t.TempDir()
+ certPath := filepath.Join(dir, "cert.pem")
+ keyPath := filepath.Join(dir, "key.pem")
+ writeTestPair(t, certPath, keyPath)
+
+ r := newCertReloader(certPath, keyPath)
+
+ first, err := r.GetCertificate(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := r.GetCertificate(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first != second {
+ t.Fatal("expected same pointer on cache hit")
+ }
+}
+
+func TestCertReloader_ReloadsOnMtimeChange(t *testing.T) {
+ dir := t.TempDir()
+ certPath := filepath.Join(dir, "cert.pem")
+ keyPath := filepath.Join(dir, "key.pem")
+ writeTestPair(t, certPath, keyPath)
+
+ r := newCertReloader(certPath, keyPath)
+ first, err := r.GetCertificate(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ writeTestPair(t, certPath, keyPath)
+ future := time.Now().Add(time.Second)
+ if err := os.Chtimes(certPath, future, future); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(keyPath, future, future); err != nil {
+ t.Fatal(err)
+ }
+
+ second, err := r.GetCertificate(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first == second {
+ t.Fatal("expected new pointer after mtime change")
+ }
+}
+
+func TestCertReloader_FallbackOnReloadError(t *testing.T) {
+ dir := t.TempDir()
+ certPath := filepath.Join(dir, "cert.pem")
+ keyPath := filepath.Join(dir, "key.pem")
+ writeTestPair(t, certPath, keyPath)
+
+ r := newCertReloader(certPath, keyPath)
+ good, err := r.GetCertificate(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(certPath, []byte("garbage"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ future := time.Now().Add(time.Second)
+ if err := os.Chtimes(certPath, future, future); err != nil {
+ t.Fatal(err)
+ }
+
+ fallback, err := r.GetCertificate(nil)
+ if err != nil {
+ t.Fatalf("expected last-good fallback, got error: %v", err)
+ }
+ if fallback != good {
+ t.Fatal("expected cached cert on corrupted file")
+ }
+}
+
+func TestCertReloader_ErrorOnFirstLoad(t *testing.T) {
+ r := newCertReloader("/nonexistent/cert.pem", "/nonexistent/key.pem")
+ if _, err := r.GetCertificate(nil); err == nil {
+ t.Fatal("expected error on missing files")
+ }
+}
diff --git a/cmd/mirumd/config.go b/cmd/mirumd/config.go
index fb7a2e0..854e7ec 100644
--- a/cmd/mirumd/config.go
+++ b/cmd/mirumd/config.go
@@ -15,7 +15,7 @@ type tlsConfig struct {
Key string `yaml:"key"`
}
-type config struct {
+type appConfig struct {
WebAddr string `yaml:"web_addr"`
GrpcAddr string `yaml:"grpc_addr"`
AdminSocket string `yaml:"admin_socket"`
@@ -31,8 +31,8 @@ type config struct {
WebhookSecret string `yaml:"webhook_secret"`
}
-func getConfig(filename string) (*config, error) {
- cfg := &config{
+func getConfig(filename string) (*appConfig, error) {
+ cfg := &appConfig{
GrpcAddr: ":2026",
WebAddr: ":3000",
AdminSocket: "/run/mirumd/admin.sock",
diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go
index d60c4eb..9898929 100644
--- a/cmd/mirumd/main.go
+++ b/cmd/mirumd/main.go
@@ -5,19 +5,14 @@ package main
import (
"context"
- "crypto/ed25519"
- "crypto/x509"
- "encoding/base64"
+ "errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
- "time"
-
- "connectrpc.com/connect"
- "github.com/google/uuid"
+ "dimidiumlabs/mirum/internal/config"
"dimidiumlabs/mirum/internal/database"
"dimidiumlabs/mirum/internal/forges"
"dimidiumlabs/mirum/internal/protocol/pb"
@@ -29,9 +24,9 @@ import (
)
func hardenServer(s *http.Server) *http.Server {
- s.IdleTimeout = 120 * time.Second
- s.MaxHeaderBytes = 1 << 16
- s.ReadHeaderTimeout = 10 * time.Second
+ s.IdleTimeout = config.HTTPIdleTimeout
+ s.MaxHeaderBytes = config.HTTPMaxHeaderBytes
+ s.ReadHeaderTimeout = config.HTTPReadHeaderTimeout
return s
}
@@ -40,234 +35,41 @@ func main() {
root := &cobra.Command{Use: "mirumd", Short: "Mirum CI server"}
root.PersistentFlags().StringVar(&socketPath, "socket", "", "admin socket path (default from config or /run/mirumd/admin.sock)")
+ root.AddGroup(&cobra.Group{ID: "main", Title: "Commands:"})
daemonCmd := &cobra.Command{
- Use: "daemon",
- Short: "Start the server",
- Run: func(cmd *cobra.Command, args []string) {
+ Use: "daemon",
+ Short: "Start the server",
+ GroupID: "main",
+ SilenceUsage: true,
+ SilenceErrors: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
configFile, _ := cmd.Flags().GetString("config")
- daemon(configFile, socketPath)
+ return daemon(configFile, socketPath)
},
}
- root.AddCommand(daemonCmd)
daemonCmd.Flags().String("config", "", "path to config file")
_ = daemonCmd.MarkFlagRequired("config")
+ root.AddCommand(daemonCmd)
- userCmd := &cobra.Command{Use: "user", Short: "Manage users"}
- root.AddCommand(userCmd)
-
- userCreateCmd := &cobra.Command{
- Use: "create",
- Short: "Create a user",
- Run: func(cmd *cobra.Command, args []string) {
- email, _ := cmd.Flags().GetString("email")
- password, _ := cmd.Flags().GetString("password")
- userCreate(socketPath, email, password)
- },
- }
- userCmd.AddCommand(userCreateCmd)
- userCreateCmd.Flags().String("email", "", "user email")
- userCreateCmd.Flags().String("password", "", "user password")
- _ = userCreateCmd.MarkFlagRequired("email")
- _ = userCreateCmd.MarkFlagRequired("password")
-
- setPasswordCmd := &cobra.Command{
- Use: "set-password",
- Short: "Set user password",
- Run: func(cmd *cobra.Command, args []string) {
- email, _ := cmd.Flags().GetString("email")
- password, _ := cmd.Flags().GetString("password")
- userSetPassword(socketPath, email, password)
- },
- }
- userCmd.AddCommand(setPasswordCmd)
- setPasswordCmd.Flags().String("email", "", "user email")
- setPasswordCmd.Flags().String("password", "", "new password")
- _ = setPasswordCmd.MarkFlagRequired("email")
- _ = setPasswordCmd.MarkFlagRequired("password")
-
- deleteUserCmd := &cobra.Command{
- Use: "delete",
- Short: "Delete a user",
- Run: func(cmd *cobra.Command, args []string) {
- email, _ := cmd.Flags().GetString("email")
- userDelete(socketPath, email)
- },
- }
- userCmd.AddCommand(deleteUserCmd)
- deleteUserCmd.Flags().String("email", "", "user email")
- _ = deleteUserCmd.MarkFlagRequired("email")
-
- workerCmd := &cobra.Command{Use: "worker", Short: "Manage workers"}
- root.AddCommand(workerCmd)
-
- workerAddCmd := &cobra.Command{
- Use: "add",
- Short: "Register a worker",
- Run: func(cmd *cobra.Command, args []string) {
- pubkey, _ := cmd.Flags().GetString("pubkey")
- workerAdd(socketPath, pubkey)
- },
- }
- workerCmd.AddCommand(workerAddCmd)
- workerAddCmd.Flags().String("pubkey", "", "base64-encoded ed25519 public key")
- _ = workerAddCmd.MarkFlagRequired("pubkey")
-
- workerRevokeCmd := &cobra.Command{
- Use: "revoke",
- Short: "Revoke a worker",
- Run: func(cmd *cobra.Command, args []string) {
- id, _ := cmd.Flags().GetString("id")
- workerRevoke(socketPath, id)
- },
- }
- workerCmd.AddCommand(workerRevokeCmd)
- workerRevokeCmd.Flags().String("id", "", "worker ID")
- _ = workerRevokeCmd.MarkFlagRequired("id")
-
- workerListCmd := &cobra.Command{
- Use: "list",
- Short: "List active workers",
- Run: func(cmd *cobra.Command, args []string) {
- workerList(socketPath)
- },
- }
- workerCmd.AddCommand(workerListCmd)
-
- orgCmd := &cobra.Command{Use: "org", Short: "Manage organizations"}
- root.AddCommand(orgCmd)
-
- orgCreateCmd := &cobra.Command{
- Use: "create",
- Short: "Create an organization",
- Run: func(cmd *cobra.Command, args []string) {
- name, _ := cmd.Flags().GetString("name")
- slug, _ := cmd.Flags().GetString("slug")
- public, _ := cmd.Flags().GetBool("public")
- owner, _ := cmd.Flags().GetString("owner")
- orgCreate(socketPath, name, slug, public, owner)
- },
- }
- orgCmd.AddCommand(orgCreateCmd)
- orgCreateCmd.Flags().String("name", "", "display name")
- orgCreateCmd.Flags().String("slug", "", "URL slug")
- orgCreateCmd.Flags().Bool("public", false, "public visibility")
- orgCreateCmd.Flags().String("owner", "", "owner email")
- _ = orgCreateCmd.MarkFlagRequired("name")
- _ = orgCreateCmd.MarkFlagRequired("slug")
- _ = orgCreateCmd.MarkFlagRequired("owner")
-
- orgDeleteCmd := &cobra.Command{
- Use: "delete",
- Short: "Delete an organization",
- Run: func(cmd *cobra.Command, args []string) {
- slug, _ := cmd.Flags().GetString("slug")
- orgDelete(socketPath, slug)
- },
- }
- orgCmd.AddCommand(orgDeleteCmd)
- orgDeleteCmd.Flags().String("slug", "", "org slug")
- _ = orgDeleteCmd.MarkFlagRequired("slug")
-
- orgRenameCmd := &cobra.Command{
- Use: "rename",
- Short: "Rename an organization",
- Run: func(cmd *cobra.Command, args []string) {
- slug, _ := cmd.Flags().GetString("slug")
- newName, _ := cmd.Flags().GetString("name")
- newSlug, _ := cmd.Flags().GetString("new-slug")
- orgRename(socketPath, slug, newName, newSlug)
- },
- }
- orgCmd.AddCommand(orgRenameCmd)
- orgRenameCmd.Flags().String("slug", "", "current slug")
- orgRenameCmd.Flags().String("name", "", "new display name")
- orgRenameCmd.Flags().String("new-slug", "", "new slug")
- _ = orgRenameCmd.MarkFlagRequired("slug")
- _ = orgRenameCmd.MarkFlagRequired("name")
- _ = orgRenameCmd.MarkFlagRequired("new-slug")
-
- orgListCmd := &cobra.Command{
- Use: "list",
- Short: "List organizations",
- Run: func(cmd *cobra.Command, args []string) {
- orgList(socketPath)
- },
- }
- orgCmd.AddCommand(orgListCmd)
-
- orgMemberAddCmd := &cobra.Command{
- Use: "add-member",
- Short: "Add a member to an organization",
- Run: func(cmd *cobra.Command, args []string) {
- org, _ := cmd.Flags().GetString("org")
- email, _ := cmd.Flags().GetString("email")
- role, _ := cmd.Flags().GetString("role")
- orgMemberAdd(socketPath, org, email, role)
- },
- }
- orgCmd.AddCommand(orgMemberAddCmd)
- orgMemberAddCmd.Flags().String("org", "", "org slug")
- orgMemberAddCmd.Flags().String("email", "", "user email")
- orgMemberAddCmd.Flags().String("role", "member", "role (owner, admin, member)")
- _ = orgMemberAddCmd.MarkFlagRequired("org")
- _ = orgMemberAddCmd.MarkFlagRequired("email")
-
- orgMemberRemoveCmd := &cobra.Command{
- Use: "remove-member",
- Short: "Remove a member from an organization",
- Run: func(cmd *cobra.Command, args []string) {
- org, _ := cmd.Flags().GetString("org")
- email, _ := cmd.Flags().GetString("email")
- orgMemberRemove(socketPath, org, email)
- },
- }
- orgCmd.AddCommand(orgMemberRemoveCmd)
- orgMemberRemoveCmd.Flags().String("org", "", "org slug")
- orgMemberRemoveCmd.Flags().String("email", "", "user email")
- _ = orgMemberRemoveCmd.MarkFlagRequired("org")
- _ = orgMemberRemoveCmd.MarkFlagRequired("email")
-
- orgSetRoleCmd := &cobra.Command{
- Use: "set-role",
- Short: "Change a member's role",
- Run: func(cmd *cobra.Command, args []string) {
- org, _ := cmd.Flags().GetString("org")
- email, _ := cmd.Flags().GetString("email")
- role, _ := cmd.Flags().GetString("role")
- orgMemberSetRole(socketPath, org, email, role)
- },
- }
- orgCmd.AddCommand(orgSetRoleCmd)
- orgSetRoleCmd.Flags().String("org", "", "org slug")
- orgSetRoleCmd.Flags().String("email", "", "user email")
- orgSetRoleCmd.Flags().String("role", "", "new role (owner, admin, member)")
- _ = orgSetRoleCmd.MarkFlagRequired("org")
- _ = orgSetRoleCmd.MarkFlagRequired("email")
- _ = orgSetRoleCmd.MarkFlagRequired("role")
-
- orgMemberListCmd := &cobra.Command{
- Use: "list-members",
- Short: "List members of an organization",
- Run: func(cmd *cobra.Command, args []string) {
- org, _ := cmd.Flags().GetString("org")
- orgMemberList(socketPath, org)
- },
+ // Admin subcommands are generated from admin.proto via reflection.
+ buildAdminCLI(root, func() pbconnect.AdminClient { return adminClient(socketPath) })
+ for _, c := range root.Commands() {
+ if c.GroupID == "" {
+ c.GroupID = "main"
+ }
}
- orgCmd.AddCommand(orgMemberListCmd)
- orgMemberListCmd.Flags().String("org", "", "org slug")
- _ = orgMemberListCmd.MarkFlagRequired("org")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
-func daemon(configFile, socketFlag string) {
+func daemon(configFile, socketFlag string) error {
cfg, err := getConfig(configFile)
if err != nil {
slog.Error("config parsing failed", "err", err)
- os.Exit(1)
+ return err
}
if socketFlag != "" {
@@ -277,28 +79,29 @@ func daemon(configFile, socketFlag string) {
slog.Info("config loaded", "configfile", configFile)
sup := supervisor.Detect()
- ctx := sup.WaitForStop(context.Background())
+ ctx, cancel := context.WithCancel(sup.WaitForStop(context.Background()))
+ defer cancel()
db, err := database.Open(ctx, cfg.DatabaseUri)
if err != nil {
- slog.Error("couldn't open database: %w", "err", err)
- os.Exit(1)
- }
- defer db.Close()
-
- if err := db.Migrate(ctx); err != nil {
- slog.Error("migration failed: %w", "err", err)
- os.Exit(1)
+ slog.Error("couldn't open database", "err", err)
+ return err
}
- slog.Info("database ready")
-
srv := &server{
db: db,
cfg: cfg,
forge: &forges.GitHub{Secret: cfg.WebhookSecret, Token: cfg.GitHubToken},
- queue: make(chan *pb.Task, 100),
+ queue: make(chan *pb.Task, config.TaskQueueCapacity),
}
+ defer srv.Close()
+
+ if err := db.Migrate(ctx); err != nil {
+ slog.Error("migration failed", "err", err)
+ return err
+ }
+
+ slog.Info("database ready")
go srv.PurgeSessions(ctx)
@@ -312,11 +115,7 @@ func daemon(configFile, socketFlag string) {
adminSrv := hardenServer(&http.Server{
Handler: adminMux,
ConnContext: func(ctx context.Context, _ net.Conn) context.Context {
- return context.WithValue(ctx, callerKey{}, &callerInfo{
- UserID: uuid.Nil,
- Email: "root@localhost",
- Superuser: true,
- })
+ return context.WithValue(ctx, actorKey{}, database.OperatorActor())
},
BaseContext: func(_ net.Listener) context.Context {
return ctx
@@ -325,67 +124,76 @@ func daemon(configFile, socketFlag string) {
grpcLn, webLn, adminLn, err := listeners(cfg)
if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
+ slog.Error("listeners failed", "err", err)
+ return err
}
slog.Info("listening", "grpc", grpcLn.Addr(), "web", webLn.Addr(), "admin", cfg.AdminSocket)
- go func() {
- var err error
+ errs := make(chan error, 3)
+ serve := func(name string, fn func() error) {
+ go func() {
+ err := fn()
+ if errors.Is(err, http.ErrServerClosed) {
+ err = nil
+ }
+ if err != nil {
+ err = fmt.Errorf("%s server: %w", name, err)
+ }
+ errs <- err
+ }()
+ }
+ serve("web", func() error {
if webSrv.TLSConfig != nil {
- err = webSrv.ServeTLS(webLn, "", "")
- } else {
- err = webSrv.Serve(webLn)
- }
- if err != nil && err != http.ErrServerClosed {
- slog.Error("web server failed", "err", err)
- os.Exit(1)
- }
- }()
- go func() {
- if err := grpcSrv.ServeTLS(grpcLn, "", ""); err != nil && err != http.ErrServerClosed {
- slog.Error("grpc server failed", "err", err)
- os.Exit(1)
- }
- }()
- go func() {
- if err := adminSrv.Serve(adminLn); err != nil && err != http.ErrServerClosed {
- slog.Error("admin server failed", "err", err)
- os.Exit(1)
+ return webSrv.ServeTLS(webLn, "", "")
}
- }()
+ return webSrv.Serve(webLn)
+ })
+ serve("grpc", func() error { return grpcSrv.ServeTLS(grpcLn, "", "") })
+ serve("admin", func() error { return adminSrv.Serve(adminLn) })
sup.Ready()
go sup.StartWatchdog(ctx)
- <-ctx.Done()
- slog.Info("shutting down")
- sup.Stopping()
+ var runErr error
+ select {
+ case <-ctx.Done():
+ slog.Info("shutting down")
+ case err := <-errs:
+ runErr = err
+ // Propagate the crash to all handler contexts so Poll and
+ // other long-lived RPCs exit via ctx.Done(); Shutdown below
+ // then completes without waiting on them.
+ cancel()
+ if err != nil {
+ slog.Error("server exited, shutting down peers", "err", err)
+ } else {
+ slog.Warn("server exited unexpectedly, shutting down peers")
+ }
+ }
- // Hard deadline: if graceful shutdown takes too long, exit.
- time.AfterFunc(30*time.Second, func() {
- slog.Error("shutdown timed out, forcing exit")
- os.Exit(1)
- })
+ sup.Stopping()
- srv.Close()
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), config.HTTPShutdownTimeout)
+ defer shutdownCancel()
- if err := webSrv.Shutdown(context.Background()); err != nil {
- slog.Error("web server shutdown", "err", err)
- }
- if err := grpcSrv.Shutdown(context.Background()); err != nil {
- slog.Error("grpc server shutdown", "err", err)
- }
- if err := adminSrv.Shutdown(context.Background()); err != nil {
- slog.Error("admin server shutdown", "err", err)
+ shutdown := func(name string, s *http.Server) {
+ if err := s.Shutdown(shutdownCtx); err != nil {
+ slog.Error("server shutdown", "name", name, "err", err)
+ }
}
+
+ shutdown("web", webSrv)
+ shutdown("grpc", grpcSrv)
+ shutdown("admin", adminSrv)
+
+ return runErr
}
// listeners returns gRPC, web, and admin listeners.
// With systemd socket activation it expects two named fds: "grpc" and "web".
// Without socket activation it falls back to configured addresses.
-func listeners(cfg *config) (grpcLn, webLn, adminLn net.Listener, err error) {
+func listeners(cfg *appConfig) (grpcLn, webLn, adminLn net.Listener, err error) {
named, err := activation.ListenersWithNames()
if err != nil {
return nil, nil, nil, fmt.Errorf("socket activation: %w", err)
@@ -445,225 +253,3 @@ func adminClient(socketPath string) pbconnect.AdminClient {
"http://localhost.unix",
)
}
-
-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,
- Password: password,
- }))
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Println(cliUUID(resp.Msg.Id))
-}
-
-func userSetPassword(socketPath, email, password string) {
- _, err := adminClient(socketPath).UserUpdate(context.Background(), connect.NewRequest(&pb.UserUpdateRequest{
- User: cliUserRef(email),
- Password: &password,
- }))
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Println("ok")
-}
-
-func userDelete(socketPath, email string) {
- _, err := adminClient(socketPath).UserDelete(context.Background(), connect.NewRequest(&pb.UserDeleteRequest{
- User: cliUserRef(email),
- }))
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Println("ok")
-}
-
-func workerAdd(socketPath, pubkeyB64 string) {
- der, err := base64.StdEncoding.DecodeString(pubkeyB64)
- if err != nil {
- fmt.Fprintln(os.Stderr, "invalid base64:", err)
- os.Exit(1)
- }
- pubkey, err := x509.ParsePKIXPublicKey(der)
- if err != nil {
- fmt.Fprintln(os.Stderr, "invalid public key:", err)
- os.Exit(1)
- }
- edKey, ok := pubkey.(ed25519.PublicKey)
- if !ok {
- fmt.Fprintln(os.Stderr, "not an ed25519 key")
- os.Exit(1)
- }
- 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(cliUUID(resp.Msg.Id))
-}
-
-func workerRevoke(socketPath, id string) {
- 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)
- os.Exit(1)
- }
- fmt.Println("ok")
-}
-
-func workerList(socketPath string) {
- resp, err := adminClient(socketPath).WorkerList(context.Background(), connect.NewRequest(&pb.WorkerListRequest{}))
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- for _, w := range resp.Msg.Workers {
- created := w.CreatedAt.AsTime().Format(time.DateOnly)
- 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,
- Owner: cliUserRef(ownerEmail),
- }))
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Println(cliUUID(resp.Msg.Id))
-}
-
-func orgDelete(socketPath, slug string) {
- _, err := adminClient(socketPath).OrgDelete(context.Background(), connect.NewRequest(&pb.OrgDeleteRequest{
- Org: cliOrgRef(slug),
- }))
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Println("ok")
-}
-
-func orgRename(socketPath, slug, newName, newSlug string) {
- _, 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)
- os.Exit(1)
- }
- fmt.Println("ok")
-}
-
-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)
- }
- for _, o := range resp.Msg.Organizations {
- visibility := "private"
- if o.Public {
- visibility = "public"
- }
- fmt.Printf("%s\t%s\t%s\t%s\n", o.Slug, o.Name, visibility, o.CreatedAt.AsTime().Format(time.DateOnly))
- }
-}
-
-func orgMemberAdd(socketPath, orgSlug, email, role string) {
- _, err := adminClient(socketPath).OrgMemberAdd(context.Background(), connect.NewRequest(&pb.OrgMemberAddRequest{
- Org: cliOrgRef(orgSlug),
- User: cliUserRef(email),
- Role: cliRoleToProto(role),
- }))
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Println("ok")
-}
-
-func orgMemberRemove(socketPath, orgSlug, email string) {
- _, err := adminClient(socketPath).OrgMemberRemove(context.Background(), connect.NewRequest(&pb.OrgMemberRemoveRequest{
- Org: cliOrgRef(orgSlug),
- User: cliUserRef(email),
- }))
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- fmt.Println("ok")
-}
-
-func orgMemberSetRole(socketPath, orgSlug, email, role string) {
- _, 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)
- os.Exit(1)
- }
- fmt.Println("ok")
-}
-
-func orgMemberList(socketPath, orgSlug string) {
- resp, err := adminClient(socketPath).OrgMemberList(context.Background(), connect.NewRequest(&pb.OrgMemberListRequest{
- 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.User.Email, m.Role, m.JoinedAt.AsTime().Format(time.DateOnly))
- }
-}
diff --git a/cmd/mirumd/server.go b/cmd/mirumd/server.go
index a93c9b8..3a86052 100644
--- a/cmd/mirumd/server.go
+++ b/cmd/mirumd/server.go
@@ -11,6 +11,7 @@ import (
"sync/atomic"
"time"
+ "dimidiumlabs/mirum/internal/config"
"dimidiumlabs/mirum/internal/database"
"dimidiumlabs/mirum/internal/forges"
"dimidiumlabs/mirum/internal/protocol/pb"
@@ -18,7 +19,7 @@ import (
// server holds the shared application state.
type server struct {
- cfg *config
+ cfg *appConfig
db *database.DB
forge forges.Forge
@@ -27,13 +28,16 @@ type server struct {
taskCounter atomic.Int64
}
+// Close releases resources owned by the server. Call exactly once, after
+// all HTTP servers have finished Shutdown.
func (s *server) Close() {
close(s.queue)
+ s.db.Close()
}
// PurgeSessions periodically deletes expired sessions until ctx is cancelled.
func (s *server) PurgeSessions(ctx context.Context) {
- ticker := time.NewTicker(1 * time.Hour)
+ ticker := time.NewTicker(config.SessionPurgeInterval)
defer ticker.Stop()
for {
diff --git a/cmd/mirumd/server_admin.go b/cmd/mirumd/server_admin.go
index 3bf8765..f758c0f 100644
--- a/cmd/mirumd/server_admin.go
+++ b/cmd/mirumd/server_admin.go
@@ -6,7 +6,7 @@ package main
import (
"context"
"errors"
- "fmt"
+ "log/slog"
"net/http"
"connectrpc.com/connect"
@@ -34,46 +34,50 @@ type adminService struct {
srv *server
}
-// anonActorID is a UUID that is not a real user — used for unauthenticated
-// public requests. RLS will show only public data for this actor.
-var anonActorID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff")
+// --- Error mapping ---
-// actorID returns the authenticated user's UUID from context,
-// or anonActorID for unauthenticated public requests.
-func actorID(ctx context.Context) uuid.UUID {
- if c := CallerFromContext(ctx); c != nil {
- return c.UserID
- }
- return anonActorID
+// 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 pb.ErrorReason, metadata map[string]string) error {
+ e := connect.NewError(code, nil)
+ if d, err := connect.NewErrorDetail(&pb.ErrorInfo{Reason: reason, Metadata: metadata}); err == nil {
+ e.AddDetail(d)
+ }
+ return e
+}
+
+var errSpecs = []struct {
+ err error
+ code connect.Code
+ reason pb.ErrorReason
+}{
+ {database.ErrUserNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_USER_NOT_FOUND},
+ {database.ErrOrgNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_ORG_NOT_FOUND},
+ {database.ErrWorkerNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_WORKER_NOT_FOUND},
+ {database.ErrNotMember, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_MEMBER_NOT_FOUND},
+ {database.ErrEmailTaken, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_EMAIL_TAKEN},
+ {database.ErrSlugTaken, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_SLUG_TAKEN},
+ {database.ErrAlreadyMember, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_ALREADY_MEMBER},
+ {database.ErrLastOwner, connect.CodeFailedPrecondition, pb.ErrorReason_ERROR_REASON_LAST_OWNER},
+ {database.ErrSoleOwner, connect.CodeFailedPrecondition, pb.ErrorReason_ERROR_REASON_SOLE_OWNER},
+ {database.ErrInvalidSlug, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_SLUG},
+ {database.ErrInvalidRole, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE},
+ {database.ErrReservedEmail, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_RESERVED_EMAIL},
+ {database.ErrFilterNotImplemented, connect.CodeUnimplemented, pb.ErrorReason_ERROR_REASON_UNIMPLEMENTED},
}
-// --- 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"))
+ 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, pb.ErrorReason_ERROR_REASON_INTERNAL, nil)
}
// --- Ref converters ---
@@ -179,7 +183,7 @@ func workerToProto(w database.Worker) *pb.Worker {
// --- 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, actorID(ctx), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper))
+ 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)
}
@@ -187,7 +191,7 @@ func (a *adminService) UserCreate(ctx context.Context, req *connect.Request[pb.U
}
func (a *adminService) UserGet(ctx context.Context, req *connect.Request[pb.UserGetRequest]) (*connect.Response[pb.UserGetResponse], error) {
- u, err := a.srv.db.GetUser(ctx, actorID(ctx), userRef(req.Msg.User))
+ u, err := a.srv.db.GetUser(ctx, ActorFromContext(ctx), userRef(req.Msg.User))
if err != nil {
return nil, mapErr(err)
}
@@ -201,7 +205,7 @@ func (a *adminService) UserList(ctx context.Context, req *connect.Request[pb.Use
filter = *req.Msg.Filter
}
- users, total, err := a.srv.db.ListUsers(ctx, actorID(ctx), cursor, limit, filter)
+ users, total, err := a.srv.db.ListUsers(ctx, ActorFromContext(ctx), cursor, limit, filter)
if err != nil {
return nil, mapErr(err)
}
@@ -223,14 +227,14 @@ func (a *adminService) UserList(ctx context.Context, req *connect.Request[pb.Use
}
func (a *adminService) UserUpdate(ctx context.Context, req *connect.Request[pb.UserUpdateRequest]) (*connect.Response[pb.UserUpdateResponse], error) {
- if err := a.srv.db.UserUpdate(ctx, actorID(ctx), userRef(req.Msg.User), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil {
+ if err := a.srv.db.UserUpdate(ctx, ActorFromContext(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.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, actorID(ctx), userRef(req.Msg.User)); err != nil {
+ if err := a.srv.db.UserDelete(ctx, ActorFromContext(ctx), userRef(req.Msg.User)); err != nil {
return nil, mapErr(err)
}
return connect.NewResponse(&pb.UserDeleteResponse{}), nil
@@ -243,7 +247,7 @@ func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.Or
if err != nil {
return nil, mapErr(err)
}
- id, err := a.srv.db.CreateOrganization(ctx, actorID(ctx), req.Msg.Name, slug, req.Msg.Public, userRef(req.Msg.Owner))
+ id, err := a.srv.db.CreateOrganization(ctx, ActorFromContext(ctx), req.Msg.Name, slug, req.Msg.Public, userRef(req.Msg.Owner))
if err != nil {
return nil, mapErr(err)
}
@@ -251,7 +255,7 @@ func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.Or
}
func (a *adminService) OrgGet(ctx context.Context, req *connect.Request[pb.OrgGetRequest]) (*connect.Response[pb.OrgGetResponse], error) {
- o, err := a.srv.db.GetOrg(ctx, actorID(ctx), orgRef(req.Msg.Org))
+ o, err := a.srv.db.GetOrg(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org))
if err != nil {
return nil, mapErr(err)
}
@@ -265,7 +269,7 @@ func (a *adminService) OrgList(ctx context.Context, req *connect.Request[pb.OrgL
filter = *req.Msg.Filter
}
- orgs, total, err := a.srv.db.ListOrganizations(ctx, actorID(ctx), cursor, limit, filter)
+ orgs, total, err := a.srv.db.ListOrganizations(ctx, ActorFromContext(ctx), cursor, limit, filter)
if err != nil {
return nil, mapErr(err)
}
@@ -295,14 +299,14 @@ func (a *adminService) OrgUpdate(ctx context.Context, req *connect.Request[pb.Or
}
slug = &s
}
- if err := a.srv.db.UpdateOrganization(ctx, actorID(ctx), orgRef(req.Msg.Org), req.Msg.Name, slug, req.Msg.Public); err != nil {
+ if err := a.srv.db.UpdateOrganization(ctx, ActorFromContext(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, actorID(ctx), orgRef(req.Msg.Org)); err != nil {
+ if err := a.srv.db.DeleteOrganization(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org)); err != nil {
return nil, mapErr(err)
}
return connect.NewResponse(&pb.OrgDeleteResponse{}), nil
@@ -313,16 +317,16 @@ func (a *adminService) OrgDelete(ctx context.Context, req *connect.Request[pb.Or
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"))
+ return nil, newAPIError(connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil)
}
- if err := a.srv.db.AddOrgMember(ctx, actorID(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil {
+ if err := a.srv.db.AddOrgMember(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil {
return nil, mapErr(err)
}
return connect.NewResponse(&pb.OrgMemberAddResponse{}), nil
}
func (a *adminService) OrgMemberGet(ctx context.Context, req *connect.Request[pb.OrgMemberGetRequest]) (*connect.Response[pb.OrgMemberGetResponse], error) {
- m, err := a.srv.db.GetOrgMember(ctx, actorID(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User))
+ m, err := a.srv.db.GetOrgMember(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User))
if err != nil {
return nil, mapErr(err)
}
@@ -336,7 +340,7 @@ func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[p
filter = *req.Msg.Filter
}
- members, total, err := a.srv.db.ListOrgMembers(ctx, actorID(ctx), orgRef(req.Msg.Org), cursor, limit, filter)
+ members, total, err := a.srv.db.ListOrgMembers(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), cursor, limit, filter)
if err != nil {
return nil, mapErr(err)
}
@@ -360,16 +364,16 @@ func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[p
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 nil, newAPIError(connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil)
}
- if err := a.srv.db.UpdateOrgMemberRole(ctx, actorID(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil {
+ if err := a.srv.db.UpdateOrgMemberRole(ctx, ActorFromContext(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, actorID(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User)); err != nil {
+ if err := a.srv.db.RemoveOrgMember(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User)); err != nil {
return nil, mapErr(err)
}
return connect.NewResponse(&pb.OrgMemberRemoveResponse{}), nil
@@ -383,7 +387,7 @@ func (a *adminService) WorkerCreate(ctx context.Context, req *connect.Request[pb
r := orgRef(req.Msg.Org)
org = &r
}
- id, err := a.srv.db.CreateWorker(ctx, actorID(ctx), req.Msg.PublicKey, org)
+ id, err := a.srv.db.CreateWorker(ctx, ActorFromContext(ctx), req.Msg.PublicKey, org)
if err != nil {
return nil, mapErr(err)
}
@@ -391,7 +395,7 @@ func (a *adminService) WorkerCreate(ctx context.Context, req *connect.Request[pb
}
func (a *adminService) WorkerGet(ctx context.Context, req *connect.Request[pb.WorkerGetRequest]) (*connect.Response[pb.WorkerGetResponse], error) {
- w, err := a.srv.db.GetWorker(ctx, actorID(ctx), uuid.UUID(req.Msg.Id))
+ w, err := a.srv.db.GetWorker(ctx, ActorFromContext(ctx), uuid.UUID(req.Msg.Id))
if err != nil {
return nil, mapErr(err)
}
@@ -405,7 +409,7 @@ func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.W
filter = *req.Msg.Filter
}
- workers, total, err := a.srv.db.ListWorkers(ctx, actorID(ctx), cursor, limit, filter)
+ workers, total, err := a.srv.db.ListWorkers(ctx, ActorFromContext(ctx), cursor, limit, filter)
if err != nil {
return nil, mapErr(err)
}
@@ -427,7 +431,7 @@ func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.W
}
func (a *adminService) WorkerDelete(ctx context.Context, req *connect.Request[pb.WorkerDeleteRequest]) (*connect.Response[pb.WorkerDeleteResponse], error) {
- if err := a.srv.db.DeleteWorker(ctx, actorID(ctx), uuid.UUID(req.Msg.Id)); err != nil {
+ if err := a.srv.db.DeleteWorker(ctx, ActorFromContext(ctx), uuid.UUID(req.Msg.Id)); err != nil {
return nil, mapErr(err)
}
return connect.NewResponse(&pb.WorkerDeleteResponse{}), nil
diff --git a/cmd/mirumd/server_grpc.go b/cmd/mirumd/server_grpc.go
index 5193457..580154e 100644
--- a/cmd/mirumd/server_grpc.go
+++ b/cmd/mirumd/server_grpc.go
@@ -17,8 +17,9 @@ import (
"connectrpc.com/connect"
"connectrpc.com/validate"
- "github.com/google/uuid"
+ "dimidiumlabs/mirum/internal/config"
+ "dimidiumlabs/mirum/internal/database"
"dimidiumlabs/mirum/internal/protocol"
"dimidiumlabs/mirum/internal/protocol/pb"
"dimidiumlabs/mirum/internal/protocol/pb/pbconnect"
@@ -34,19 +35,18 @@ func NewGrpcServer(ctx context.Context, srv *server) *http.Server {
mux := http.NewServeMux()
mux.Handle(path, workerLog(handler))
+ certs := newCertReloader(srv.cfg.GrpcTls.Cert, srv.cfg.GrpcTls.Key)
+
return &http.Server{
Handler: mux,
BaseContext: func(_ net.Listener) context.Context {
return ctx
},
TLSConfig: &tls.Config{
- NextProtos: []string{"h2"},
- MinVersion: tls.VersionTLS13,
- ClientAuth: tls.RequireAnyClientCert,
- GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
- cert, err := tls.LoadX509KeyPair(srv.cfg.GrpcTls.Cert, srv.cfg.GrpcTls.Key)
- return &cert, err
- },
+ NextProtos: []string{"h2"},
+ MinVersion: tls.VersionTLS13,
+ ClientAuth: tls.RequireAnyClientCert,
+ GetCertificate: certs.GetCertificate,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("client certificate required")
@@ -62,14 +62,14 @@ func NewGrpcServer(ctx context.Context, srv *server) *http.Server {
return errors.New("ed25519 certificate required")
}
- if _, err := srv.db.LookupWorker(context.Background(), uuid.Nil, pubKey); err != nil {
+ if _, err := srv.db.LookupWorker(context.Background(), database.SystemActor(), pubKey); err != nil {
return fmt.Errorf("unknown worker: %w", err)
}
// Clock skew: NotBefore is set to time.Now() when the cert was generated.
// Checked here (once per TLS handshake), not in the interceptor,
// because HTTP/2 reuses the connection and NotBefore would go stale.
- if skew := time.Since(c.NotBefore).Abs(); skew > time.Minute {
+ if skew := time.Since(c.NotBefore).Abs(); skew > config.WorkerClockSkewLimit {
return fmt.Errorf("%w: %s", protocol.ErrClockSkew, skew.Truncate(time.Second))
}
diff --git a/cmd/mirumd/server_web.go b/cmd/mirumd/server_web.go
index 7ce1a7f..877d700 100644
--- a/cmd/mirumd/server_web.go
+++ b/cmd/mirumd/server_web.go
@@ -11,19 +11,20 @@ import (
"encoding/base64"
"errors"
"io"
+ "log/slog"
"net"
"net/http"
+ "runtime/debug"
"strings"
- "time"
-
- "github.com/google/uuid"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
+ "dimidiumlabs/mirum/internal/config"
"dimidiumlabs/mirum/internal/database"
"dimidiumlabs/mirum/internal/forges"
+ "dimidiumlabs/mirum/internal/protocol/pb"
)
// __Host- prefixed cookies can only be set with Secure, Path=/, and no
@@ -46,11 +47,11 @@ func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandl
r.Use(middleware.StripSlashes)
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
- r.Use(middleware.Recoverer)
+ r.Use(h.recoverer)
r.Use(middleware.Compress(5))
r.Use(middleware.Heartbeat("/ping"))
- r.Use(middleware.Timeout(30 * time.Second))
- r.Use(middleware.RequestSize(64 << 20)) // 64 MiB global body limit
+ r.Use(middleware.Timeout(config.WebRequestTimeout))
+ r.Use(middleware.RequestSize(config.WebMaxBodyBytes))
r.Use(trustedProxyMiddleware(srv.cfg.TrustedProxies))
r.Use(func(next http.Handler) http.Handler {
@@ -81,25 +82,30 @@ func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandl
r.Route("/auth", func(r chi.Router) {
r.Use(middleware.NoCache)
- r.Use(httprate.LimitByIP(10, time.Minute))
- r.Use(middleware.RequestSize(4096))
+ r.Use(httprate.LimitByIP(config.AuthRateLimit, config.AuthRateWindow))
+ r.Use(middleware.RequestSize(config.AuthMaxBodyBytes))
r.Get("/login", h.loginPage)
r.Post("/login", h.login)
r.Post("/logout", h.logout)
})
- r.With(middleware.NoCache, httprate.LimitByIP(300, time.Minute)).
+ r.With(middleware.NoCache, httprate.LimitByIP(config.APIRateLimit, config.APIRateWindow)).
Mount("/api/v1", http.StripPrefix("/api/v1", adminHandler))
+ r.NotFound(func(w http.ResponseWriter, r *http.Request) {
+ h.renderError(w, r, http.StatusNotFound)
+ })
+ r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
+ h.renderError(w, r, http.StatusMethodNotAllowed)
+ })
+
var tlsCfg *tls.Config
if srv.cfg.WebTls != nil {
+ certs := newCertReloader(srv.cfg.WebTls.Cert, srv.cfg.WebTls.Key)
tlsCfg = &tls.Config{
- MinVersion: tls.VersionTLS13,
- GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
- cert, err := tls.LoadX509KeyPair(srv.cfg.WebTls.Cert, srv.cfg.WebTls.Key)
- return &cert, err
- },
+ MinVersion: tls.VersionTLS13,
+ GetCertificate: certs.GetCertificate,
}
}
@@ -112,38 +118,27 @@ func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandl
}
}
-type callerKey struct{}
-
-type callerInfo struct {
- UserID uuid.UUID
- Email string
- Superuser bool
-}
+type actorKey struct{}
type webHandler struct {
srv *server
assets *assetResolver
}
-// CallerFromContext returns the authenticated caller, or nil.
-func CallerFromContext(ctx context.Context) *callerInfo {
- if v, ok := ctx.Value(callerKey{}).(*callerInfo); ok {
+// ActorFromContext returns the authenticated actor, or AnonActor if none.
+func ActorFromContext(ctx context.Context) database.Actor {
+ if v, ok := ctx.Value(actorKey{}).(database.Actor); ok {
return v
}
- return nil
+ return database.AnonActor()
}
-// SessionMiddleware resolves the session cookie and puts callerInfo in context.
+// SessionMiddleware resolves the session cookie and puts the Actor in context.
func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
- if sess, err := h.srv.db.UserGetSession(r.Context(), uuid.Nil, c.Value); err == nil {
- caller := &callerInfo{
- UserID: sess.UserID,
- Email: sess.Email,
- Superuser: sess.Superuser,
- }
- ctx := context.WithValue(r.Context(), callerKey{}, caller)
+ if actor, err := h.srv.db.UserGetSession(r.Context(), database.SystemActor(), c.Value); err == nil {
+ ctx := context.WithValue(r.Context(), actorKey{}, actor)
r = r.WithContext(ctx)
}
}
@@ -151,26 +146,56 @@ func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler {
})
}
-type authedHandler func(w http.ResponseWriter, r *http.Request, caller *callerInfo)
+type authedHandler func(w http.ResponseWriter, r *http.Request, actor database.Actor)
func authonly(next authedHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- caller := CallerFromContext(r.Context())
- if caller == nil {
+ actor := ActorFromContext(r.Context())
+ if actor.Kind() == database.KindAnon {
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
return
}
- next(w, r, caller)
+ next(w, r, actor)
}
}
-func (h *webHandler) index(w http.ResponseWriter, r *http.Request, caller *callerInfo) {
- h.assets.renderPage(w, "dashboard", map[string]any{
- "user": map[string]string{"email": caller.Email},
+func (h *webHandler) index(w http.ResponseWriter, r *http.Request, actor database.Actor) {
+ h.assets.renderPage(w, "dashboard", http.StatusOK, map[string]any{
+ "user": map[string]string{"email": actor.Email()},
"csrf": csrfToken(w, r),
})
}
+// renderError serves the error page with the given HTTP status.
+func (h *webHandler) renderError(w http.ResponseWriter, r *http.Request, status int) {
+ h.assets.renderPage(w, "error", status, map[string]any{"status": status})
+}
+
+// recoverer catches panics, logs them, and renders the 500 page so the
+// client sees something more useful than chi's plaintext default. The
+// http.ErrAbortHandler sentinel is re-raised so net/http's server
+// machinery can recognise an intentional handler abort.
+func (h *webHandler) recoverer(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ rvr := recover()
+ if rvr == nil {
+ return
+ }
+ if rvr == http.ErrAbortHandler {
+ panic(rvr)
+ }
+ slog.Error("panic",
+ "err", rvr,
+ "path", r.URL.Path,
+ "stack", string(debug.Stack()),
+ )
+ h.renderError(w, r, http.StatusInternalServerError)
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
+
func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -197,34 +222,40 @@ func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) {
}
func (h *webHandler) loginPage(w http.ResponseWriter, r *http.Request) {
- h.assets.renderPage(w, "login", map[string]any{
- "csrf": csrfToken(w, r),
- })
+ h.renderLogin(w, r, http.StatusOK, pb.ErrorReason_ERROR_REASON_UNSPECIFIED)
+}
+
+// renderLogin is the single entry point for every login-flow outcome that
+// lands back on the login page. Reason == UNSPECIFIED means no error banner.
+// No caller writes error text itself — the client maps reason → copy.
+func (h *webHandler) renderLogin(w http.ResponseWriter, r *http.Request, status int, reason pb.ErrorReason) {
+ data := map[string]any{"csrf": csrfToken(w, r)}
+ if reason != pb.ErrorReason_ERROR_REASON_UNSPECIFIED {
+ data["errorReason"] = int32(reason)
+ }
+ h.assets.renderPage(w, "login", status, data)
}
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
if !csrfOK(r) {
clearCookie(w, csrfCookie)
- http.Error(w, "invalid csrf token", http.StatusForbidden)
+ h.renderLogin(w, r, http.StatusForbidden, pb.ErrorReason_ERROR_REASON_INVALID_CSRF)
return
}
email := r.FormValue("email")
password := r.FormValue("password")
- userID, err := h.srv.db.UserVerifyPassword(r.Context(), uuid.Nil, email, password, []byte(h.srv.cfg.Pepper))
+ userID, err := h.srv.db.UserVerifyPassword(r.Context(), database.SystemActor(), email, password, []byte(h.srv.cfg.Pepper))
if err != nil {
- w.WriteHeader(http.StatusUnauthorized)
- h.assets.renderPage(w, "login", map[string]any{
- "csrf": csrfToken(w, r),
- "error": "Wrong email or password. Please try again.",
- })
+ h.renderLogin(w, r, http.StatusUnauthorized, pb.ErrorReason_ERROR_REASON_INVALID_CREDENTIALS)
return
}
- token, err := h.srv.db.UserCreateSession(r.Context(), uuid.Nil, userID)
+ token, err := h.srv.db.UserCreateSession(r.Context(), database.SystemActor(), userID)
if err != nil {
- http.Error(w, "internal error", http.StatusInternalServerError)
+ slog.Error("create session failed", "err", err)
+ h.renderLogin(w, r, http.StatusInternalServerError, pb.ErrorReason_ERROR_REASON_INTERNAL)
return
}
@@ -235,18 +266,20 @@ func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
- MaxAge: int(database.SessionTTL.Seconds()),
+ MaxAge: int(config.SessionTTL.Seconds()),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
if !csrfOK(r) {
- http.Error(w, "invalid csrf token", http.StatusForbidden)
+ // Forged logout attempt — ignore silently. Session stays valid,
+ // user ends up wherever / takes them.
+ http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if c, err := r.Cookie(sessionCookie); err == nil {
- h.srv.db.UserDeleteSession(r.Context(), uuid.Nil, c.Value)
+ h.srv.db.UserDeleteSession(r.Context(), database.SystemActor(), c.Value)
}
clearCookie(w, sessionCookie)
clearCookie(w, csrfCookie)
@@ -270,6 +303,7 @@ func csrfToken(w http.ResponseWriter, r *http.Request) string {
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
+ MaxAge: int(config.SessionTTL.Seconds()),
})
return token
}
diff --git a/cmd/mirumd/static.go b/cmd/mirumd/static.go
index 3a23159..bc304ba 100644
--- a/cmd/mirumd/static.go
+++ b/cmd/mirumd/static.go
@@ -183,7 +183,7 @@ window.__vite_plugin_react_preamble_installed__ = true`,
return ar.assets[name]
}
-func (ar *assetResolver) renderPage(w http.ResponseWriter, entry string, data any) {
+func (ar *assetResolver) renderPage(w http.ResponseWriter, entry string, status int, data any) {
dataJSON, err := json.Marshal(data)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -197,6 +197,9 @@ func (ar *assetResolver) renderPage(w http.ResponseWriter, entry string, data an
if assets.CSP != "" {
w.Header().Set("Content-Security-Policy", assets.CSP)
}
+ if status != 0 && status != http.StatusOK {
+ w.WriteHeader(status)
+ }
if err := shellTmpl.Execute(w, struct {
DataJSON template.JS
diff --git a/cmd/mirumd/web/api/client.ts b/cmd/mirumd/web/api/client.ts
index a40e2c0..5d0ebc3 100644
--- a/cmd/mirumd/web/api/client.ts
+++ b/cmd/mirumd/web/api/client.ts
@@ -1,17 +1,34 @@
// Copyright (c) 2026 Nikolay Govorov
// SPDX-License-Identifier: AGPL-3.0-or-later
-import { createClient } from "@connectrpc/connect"
+import { createClient, type Interceptor } from "@connectrpc/connect"
import { createConnectTransport } from "@connectrpc/connect-web"
-import { Admin } from "@/gen/admin_pb"
+import { Admin, ErrorReason } from "@/gen/admin_pb"
+import { errorReason } from "@/lib/errors"
+
+const csrfInterceptor = (csrfToken: string): Interceptor =>
+ (next) => async (req) => {
+ req.header.set("X-CSRF-Token", csrfToken)
+ return next(req)
+ }
+
+// authInterceptor redirects to /auth/login on Unauthenticated so individual
+// callers don't need to handle session expiry explicitly.
+const authInterceptor: Interceptor = (next) => async (req) => {
+ try {
+ return await next(req)
+ } catch (err) {
+ if (errorReason(err) === ErrorReason.UNAUTHENTICATED) {
+ window.location.assign("/auth/login")
+ }
+ throw err
+ }
+}
export function createAdminClient(csrfToken: string) {
const transport = createConnectTransport({
baseUrl: "/api/v1",
- interceptors: [(next) => async (req) => {
- req.header.set("X-CSRF-Token", csrfToken)
- return next(req)
- }],
+ interceptors: [csrfInterceptor(csrfToken), authInterceptor],
})
return createClient(Admin, transport)
}
diff --git a/cmd/mirumd/web/components/pages/dashboard.tsx b/cmd/mirumd/web/components/pages/dashboard.tsx
index 41420bc..51c2f83 100644
--- a/cmd/mirumd/web/components/pages/dashboard.tsx
+++ b/cmd/mirumd/web/components/pages/dashboard.tsx
@@ -1,8 +1,9 @@
// Copyright (c) 2026 Nikolay Govorov
// SPDX-License-Identifier: AGPL-3.0-or-later
-import { useEffect, useState } from "react"
+import { useEffect, useMemo, useState } from "react"
import { createAdminClient } from "@/api/client"
+import { formatError } from "@/lib/errors"
import type { Org } from "@/gen/admin_pb"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -13,16 +14,21 @@ export type DashboardProps = {
}
export function Page({ user, csrf }: DashboardProps) {
+ const client = useMemo(() => createAdminClient(csrf), [csrf])
const [orgs, setOrgs] = useState<Org[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
- const client = createAdminClient(csrf)
+ const ac = new AbortController()
client
- .orgList({})
+ .orgList({}, { signal: ac.signal })
.then((res) => setOrgs(res.organizations))
- .catch((err: unknown) => setError(String(err)))
- }, [csrf])
+ .catch((err: unknown) => {
+ if (ac.signal.aborted) return
+ setError(formatError(err))
+ })
+ return () => ac.abort()
+ }, [client])
return (
<div className="mx-auto max-w-3xl p-8 space-y-6">
diff --git a/cmd/mirumd/web/components/pages/error.tsx b/cmd/mirumd/web/components/pages/error.tsx
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/web/components/pages/error.tsx
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { Button } from "@/components/ui/button"
+
+export type ErrorPageProps = {
+ status: number
+}
+
+const copy: Record<number, { title: string; body: string }> = {
+ 400: { title: "Bad request", body: "The request couldn't be processed." },
+ 401: { title: "Not signed in", body: "Please sign in to continue." },
+ 403: { title: "Forbidden", body: "You don't have permission to access this page." },
+ 404: { title: "Not found", body: "The page you were looking for doesn't exist." },
+ 405: { title: "Method not allowed", body: "That action isn't supported here." },
+ 500: { title: "Something went wrong", body: "An internal error occurred. Please try again." },
+ 503: { title: "Unavailable", body: "The server is temporarily unable to handle the request." },
+}
+
+export function Page({ status }: ErrorPageProps) {
+ const text = copy[status] ?? { title: `Error ${status}`, body: "Something went wrong." }
+
+ return (
+ <main className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
+ <p
+ aria-hidden
+ className="pointer-events-none absolute inset-x-0 top-1/2 hidden -translate-y-1/2 select-none text-center font-bold leading-none tracking-tighter text-foreground/[0.04] text-[clamp(12rem,32vw,24rem)] md:block"
+ >
+ {status}
+ </p>
+ <div className="relative w-full max-w-sm">
+ <p className="text-xs font-medium uppercase tracking-wider text-foreground/60">
+ Error {status}
+ </p>
+ <h1 className="mt-2 text-2xl font-semibold tracking-tight">
+ {text.title}
+ </h1>
+ <p className="mt-3 text-sm text-foreground/80">
+ {text.body}
+ </p>
+ <Button asChild variant="outline" size="sm" className="mt-6">
+ <a href="/">Back to home</a>
+ </Button>
+ </div>
+ </main>
+ )
+}
diff --git a/cmd/mirumd/web/components/pages/login.tsx b/cmd/mirumd/web/components/pages/login.tsx
index 4e11fc6..71d07db 100644
--- a/cmd/mirumd/web/components/pages/login.tsx
+++ b/cmd/mirumd/web/components/pages/login.tsx
@@ -7,14 +7,18 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
+import { ErrorReason } from "@/gen/admin_pb"
+import { textForReason } from "@/lib/errors"
export type LoginFormProps = {
csrf: string
- error?: string
+ errorReason?: ErrorReason
}
-export function Page({ csrf, error: initialError }: LoginFormProps) {
- const [error, setError] = useState(initialError)
+export function Page({ csrf, errorReason }: LoginFormProps) {
+ const [error, setError] = useState(
+ errorReason !== undefined ? textForReason(errorReason) : undefined,
+ )
const dismissError = () => setError(undefined)
return (
diff --git a/cmd/mirumd/web/entries/error.tsx b/cmd/mirumd/web/entries/error.tsx
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/web/entries/error.tsx
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import "@/index.css"
+import { mountPage } from "@/lib/mount"
+import { Page } from "@/components/pages/error"
+
+mountPage(Page)
diff --git a/cmd/mirumd/web/lib/errors.ts b/cmd/mirumd/web/lib/errors.ts
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/web/lib/errors.ts
@@ -0,0 +1,84 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { Code, ConnectError } from "@connectrpc/connect"
+import { ErrorInfoSchema, ErrorReason } from "@/gen/admin_pb"
+
+// errorReason extracts the ErrorInfo.reason attached by the server.
+// Returns null for transport failures or non-mirum responses.
+export function errorReason(err: unknown): ErrorReason | null {
+ const info = ConnectError.from(err).findDetails(ErrorInfoSchema)[0]
+ return info?.reason ?? null
+}
+
+// formatError maps any thrown value to user-facing text.
+// All .catch() callers should route errors through this function.
+export function formatError(err: unknown): string {
+ const e = ConnectError.from(err)
+ const info = e.findDetails(ErrorInfoSchema)[0]
+ if (info) {
+ return textForReason(info.reason)
+ }
+ console.error("api error without ErrorInfo:", e)
+ return textForCode(e.code)
+}
+
+export function textForReason(reason: ErrorReason): string {
+ switch (reason) {
+ case ErrorReason.USER_NOT_FOUND:
+ return "User not found."
+ case ErrorReason.ORG_NOT_FOUND:
+ return "Organization not found."
+ case ErrorReason.WORKER_NOT_FOUND:
+ return "Worker not found."
+ case ErrorReason.MEMBER_NOT_FOUND:
+ return "Member not found."
+ case ErrorReason.EMAIL_TAKEN:
+ return "This email is already in use."
+ case ErrorReason.SLUG_TAKEN:
+ return "This slug is already taken."
+ case ErrorReason.ALREADY_MEMBER:
+ return "Already a member of this organization."
+ case ErrorReason.LAST_OWNER:
+ return "An organization must have at least one owner."
+ case ErrorReason.SOLE_OWNER:
+ return "This user is the sole owner of an organization. Transfer ownership first."
+ case ErrorReason.INVALID_SLUG:
+ return "Invalid slug. Use lowercase letters, digits, and hyphens."
+ case ErrorReason.INVALID_ROLE:
+ return "Invalid role."
+ case ErrorReason.RESERVED_EMAIL:
+ return "This email domain is reserved. Please use a different address."
+ case ErrorReason.UNAUTHENTICATED:
+ return "Your session has expired. Please sign in again."
+ case ErrorReason.PERMISSION_DENIED:
+ return "You don't have permission to do that."
+ case ErrorReason.INVALID_CREDENTIALS:
+ return "Wrong email or password. Please try again."
+ case ErrorReason.INVALID_CSRF:
+ return "This form expired. Please try again."
+ case ErrorReason.RATE_LIMITED:
+ return "Too many requests. Please slow down."
+ case ErrorReason.UNAVAILABLE:
+ return "Service is temporarily unavailable. Please retry."
+ case ErrorReason.UNIMPLEMENTED:
+ return "This operation is not supported."
+ case ErrorReason.INTERNAL:
+ case ErrorReason.UNSPECIFIED:
+ default:
+ return "Something went wrong. Please try again."
+ }
+}
+
+function textForCode(code: Code): string {
+ switch (code) {
+ case Code.Canceled:
+ return "Request canceled."
+ case Code.DeadlineExceeded:
+ return "Request timed out."
+ case Code.Unavailable:
+ return "Service is temporarily unavailable. Please retry."
+ default:
+ return "Connection failed. Please retry."
+ }
+}
diff --git a/cmd/mirumd/web/shell.html b/cmd/mirumd/web/shell.html
index 7c21edf..ce74041 100644
--- a/cmd/mirumd/web/shell.html
+++ b/cmd/mirumd/web/shell.html
@@ -19,6 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</head>
<body>
<div id="app"></div>
+ <noscript>
+ <p style="max-width:32rem;margin:4rem auto;padding:1rem;font:14px system-ui,sans-serif;text-align:center">
+ Mirum requires JavaScript to run. Please enable it in your browser.
+ </p>
+ </noscript>
<script type="application/json" id="__DATA__">{{.DataJSON}}</script>
{{- if .Preamble}}
<script type="module">{{.Preamble}}</script>
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,54 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package config holds hard-coded tunables shared across mirumd and mirumw:
+// timeouts, sizes, intervals, and limits that are not (yet) exposed through
+// the YAML user config. Grouping them here keeps magic numbers out of call
+// sites and gives a single place to audit defaults.
+package config
+
+import "time"
+
+// HTTP server hardening — applied to every *http.Server by hardenServer.
+const (
+ HTTPIdleTimeout = 120 * time.Second
+ HTTPReadHeaderTimeout = 10 * time.Second
+ HTTPMaxHeaderBytes = 1 << 16 // 64 KiB
+ HTTPShutdownTimeout = 30 * time.Second
+)
+
+// Web router middleware — chi.
+const (
+ WebRequestTimeout = 30 * time.Second
+ WebMaxBodyBytes = 64 << 20 // 64 MiB
+
+ // /auth routes: tighter body cap and per-IP rate limit.
+ AuthMaxBodyBytes = 4096
+ AuthRateLimit = 10
+ AuthRateWindow = time.Minute
+
+ // /api/v1 routes: SPA-friendly per-IP rate limit.
+ APIRateLimit = 300
+ APIRateWindow = time.Minute
+)
+
+// Task queue — channel buffer between webhook handlers and gRPC Poll.
+const TaskQueueCapacity = 100
+
+// Sessions.
+const (
+ SessionTTL = 14 * 24 * time.Hour // cookie + DB row lifetime
+ SessionPurgeInterval = time.Hour // background PurgeExpiredSessions
+)
+
+// Worker mTLS handshake.
+const (
+ WorkerCertLifetime = 24 * time.Hour // self-signed worker cert NotAfter
+ WorkerClockSkewLimit = time.Minute // max allowed skew between worker and server
+)
+
+// Worker reconnection backoff (exponential, jittered).
+const (
+ WorkerBackoffMin = time.Second
+ WorkerBackoffMax = 60 * time.Second
+)
diff --git a/internal/database/actor.go b/internal/database/actor.go
new file mode 100644
--- /dev/null
+++ b/internal/database/actor.go
@@ -0,0 +1,117 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package database
+
+import "github.com/google/uuid"
+
+// Actor is the principal making a database request. It carries identity,
+// display metadata, and coarse capability. Zero value is invalid: dbID
+// panics, so a missing initialisation cannot silently grant privileges.
+//
+// Synthetic actors (System/Operator/Anon) live only as Go constants —
+// they are not rows in the users table, so they cannot be logged in as
+// even if somebody writes a password into the DB.
+type Actor struct {
+ kind actorKind
+ id uuid.UUID
+ email string
+ superuser bool
+}
+
+type actorKind uint8
+
+const (
+ actorInvalid actorKind = iota
+ actorUser
+ actorOperator
+ actorSystem
+ actorAnon
+)
+
+// ActorKind is the exported form of actorKind for audit sinks and logging.
+type ActorKind uint8
+
+const (
+ KindInvalid ActorKind = iota
+ KindUser
+ KindOperator
+ KindSystem
+ KindAnon
+)
+
+var (
+ systemUUID = uuid.MustParse("00000000-0000-0000-0000-000000000001")
+ operatorUUID = uuid.MustParse("00000000-0000-0000-0000-000000000002")
+ anonUUID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff")
+)
+
+// UserActor identifies an authenticated user from a session or token.
+func UserActor(id uuid.UUID, email string, superuser bool) Actor {
+ if id == uuid.Nil {
+ panic("database: UserActor with nil UUID")
+ }
+ if email == "" {
+ panic("database: UserActor with empty email")
+ }
+ return Actor{kind: actorUser, id: id, email: email, superuser: superuser}
+}
+
+// OperatorActor is the principal for externally invoked privileged
+// operations (admin socket). Distinguishable from System in audit logs.
+func OperatorActor() Actor {
+ return Actor{kind: actorOperator, id: operatorUUID, email: "operator@mirum.local", superuser: true}
+}
+
+// SystemActor is the principal for internal machinery (mTLS handshake,
+// session bootstrap, background jobs). Not an operator action.
+func SystemActor() Actor {
+ return Actor{kind: actorSystem, id: systemUUID, email: "system@mirum.local", superuser: true}
+}
+
+// AnonActor is the principal for unauthenticated public requests.
+func AnonActor() Actor {
+ return Actor{kind: actorAnon, id: anonUUID, email: "anonymous@mirum.local"}
+}
+
+func (a Actor) Kind() ActorKind {
+ switch a.kind {
+ case actorUser:
+ return KindUser
+ case actorOperator:
+ return KindOperator
+ case actorSystem:
+ return KindSystem
+ case actorAnon:
+ return KindAnon
+ }
+ return KindInvalid
+}
+
+func (a Actor) UserID() uuid.UUID { return a.id }
+func (a Actor) Email() string { return a.email }
+func (a Actor) IsSuperuser() bool { return a.superuser }
+
+// dbID returns the UUID to write into app.user_id. Panics on zero value.
+func (a Actor) dbID() uuid.UUID {
+ if a.id == uuid.Nil {
+ panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor")
+ }
+ return a.id
+}
+
+// kindString returns the string written into app.actor_kind.
+// It must match the values tested by app_issuper() in the SQL migration.
+func (a Actor) kindString() string {
+ switch a.kind {
+ case actorUser:
+ return "user"
+ case actorOperator:
+ return "operator"
+ case actorSystem:
+ return "system"
+ case actorAnon:
+ return "anon"
+ }
+ panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor")
+}
diff --git a/internal/database/database.go b/internal/database/database.go
index 7a2ff26..be01a16 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -7,7 +7,6 @@ import (
"context"
"errors"
- "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/tern/v2/migrate"
@@ -46,13 +45,19 @@ func (db *DB) Close() {
db.Pool.Close()
}
-// beginAs starts a transaction and sets the RLS actor.
-func (db *DB) beginAs(ctx context.Context, actor uuid.UUID) (pgx.Tx, error) {
+// beginAs starts a transaction and sets the RLS actor. Both app.user_id
+// and app.actor_kind are populated: app_issuper() checks actor_kind for
+// System/Operator principals, and app.user_id for real user superusers.
+func (db *DB) beginAs(ctx context.Context, actor Actor) (pgx.Tx, error) {
tx, err := db.Pool.Begin(ctx)
if err != nil {
return nil, err
}
- if _, err := tx.Exec(ctx, "SELECT set_config('app.user_id', $1, true)", actor.String()); err != nil {
+ if _, err := tx.Exec(ctx,
+ `SELECT set_config('app.user_id', $1, true),
+ set_config('app.actor_kind', $2, true)`,
+ actor.dbID().String(), actor.kindString(),
+ ); err != nil {
tx.Rollback(ctx)
return nil, err
}
@@ -69,8 +74,9 @@ func (db *DB) Migrate(ctx context.Context) error {
// NOTE: RLS is active on all tables. Migrations with DML (INSERT/UPDATE/DELETE)
// must prefix the SQL with:
- // SELECT set_config('app.user_id', '00000000-0000-0000-0000-000000000000', true);
- // This sets the root superuser for the migration's transaction only.
+ // SELECT set_config('app.actor_kind', 'system', true);
+ // This flips app_issuper() via the actor_kind branch for the migration's
+ // transaction only. No synthetic row in users is needed.
migrator, err := migrate.NewMigrator(ctx, conn.Conn(), "schema_version")
if err != nil {
return errors.Join(ErrMigrate, err)
@@ -86,19 +92,25 @@ func (db *DB) Migrate(ctx context.Context) error {
deleted_at TIMESTAMPTZ
);
- INSERT INTO users (id, email, password, superuser)
- VALUES ('00000000-0000-0000-0000-000000000000', 'root@localhost', '', true);
-
CREATE FUNCTION app_user_id() RETURNS uuid STABLE AS $$
SELECT current_setting('app.user_id', true)::uuid;
$$ LANGUAGE sql;
+ -- app_issuper has two independent branches:
+ -- (a) runtime setting app.actor_kind is 'system' or 'operator' —
+ -- set only by beginAs from Go for synthetic principals and by
+ -- DML migrations; cannot be injected via login since there is
+ -- no matching users row to authenticate against.
+ -- (b) the current app.user_id resolves to a users row with
+ -- superuser = true — real support-agent style superusers.
CREATE FUNCTION app_issuper() RETURNS boolean STABLE AS $$
- SELECT EXISTS (
- SELECT 1 FROM users
- WHERE id = current_setting('app.user_id', true)::uuid
- AND superuser = true
- );
+ SELECT
+ current_setting('app.actor_kind', true) IN ('system', 'operator')
+ OR EXISTS (
+ SELECT 1 FROM users
+ WHERE id = current_setting('app.user_id', true)::uuid
+ AND superuser = true
+ );
$$ LANGUAGE sql;
`, `
DROP FUNCTION app_issuper;
diff --git a/internal/database/organization.go b/internal/database/organization.go
index cca7dff..e5e8a3b 100644
--- a/internal/database/organization.go
+++ b/internal/database/organization.go
@@ -78,7 +78,7 @@ func resolveOrg(ctx context.Context, tx pgx.Tx, ref OrgRef) (uuid.UUID, error) {
}
// GetOrg returns an org by ref (ID or slug).
-func (db *DB) GetOrg(ctx context.Context, actor uuid.UUID, ref OrgRef) (*Organization, error) {
+func (db *DB) GetOrg(ctx context.Context, actor Actor, ref OrgRef) (*Organization, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return nil, err
@@ -105,7 +105,7 @@ func (db *DB) GetOrg(ctx context.Context, actor uuid.UUID, ref OrgRef) (*Organiz
}
// CreateOrganization creates an org and adds the owner as the first member.
-func (db *DB) CreateOrganization(ctx context.Context, actor uuid.UUID, name, slug string, public bool, owner UserRef) (uuid.UUID, error) {
+func (db *DB) CreateOrganization(ctx context.Context, actor Actor, name, slug string, public bool, owner UserRef) (uuid.UUID, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return uuid.Nil, err
@@ -141,7 +141,7 @@ func (db *DB) CreateOrganization(ctx context.Context, actor uuid.UUID, name, slu
}
// UpdateOrganization updates an org's name, slug, and/or public flag.
-func (db *DB) UpdateOrganization(ctx context.Context, actor uuid.UUID, ref OrgRef, name *string, slug *string, public *bool) error {
+func (db *DB) UpdateOrganization(ctx context.Context, actor Actor, ref OrgRef, name *string, slug *string, public *bool) error {
if name == nil && slug == nil && public == nil {
return nil
}
@@ -184,7 +184,7 @@ func (db *DB) UpdateOrganization(ctx context.Context, actor uuid.UUID, ref OrgRe
}
// DeleteOrganization soft-deletes an org and removes all members.
-func (db *DB) DeleteOrganization(ctx context.Context, actor uuid.UUID, ref OrgRef) error {
+func (db *DB) DeleteOrganization(ctx context.Context, actor Actor, ref OrgRef) error {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return err
@@ -210,7 +210,7 @@ func (db *DB) DeleteOrganization(ctx context.Context, actor uuid.UUID, ref OrgRe
}
// ListOrganizations returns a page of orgs and the total count.
-func (db *DB) ListOrganizations(ctx context.Context, actor uuid.UUID, cursor uuid.UUID, limit int, filter string) ([]Organization, int, error) {
+func (db *DB) ListOrganizations(ctx context.Context, actor Actor, cursor uuid.UUID, limit int, filter string) ([]Organization, int, error) {
if filter != "" {
return nil, 0, ErrFilterNotImplemented
}
@@ -257,7 +257,7 @@ func (db *DB) ListOrganizations(ctx context.Context, actor uuid.UUID, cursor uui
}
// GetOrgMember returns a single member's info.
-func (db *DB) GetOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, user UserRef) (*OrgMember, error) {
+func (db *DB) GetOrgMember(ctx context.Context, actor Actor, org OrgRef, user UserRef) (*OrgMember, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return nil, err
@@ -289,7 +289,7 @@ func (db *DB) GetOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, use
}
// ListOrgMembers returns a page of members for an org.
-func (db *DB) ListOrgMembers(ctx context.Context, actor uuid.UUID, org OrgRef, cursor uuid.UUID, limit int, filter string) ([]OrgMember, int, error) {
+func (db *DB) ListOrgMembers(ctx context.Context, actor Actor, org OrgRef, cursor uuid.UUID, limit int, filter string) ([]OrgMember, int, error) {
if filter != "" {
return nil, 0, ErrFilterNotImplemented
}
@@ -344,7 +344,7 @@ func (db *DB) ListOrgMembers(ctx context.Context, actor uuid.UUID, org OrgRef, c
}
// AddOrgMember adds a user to an org with the given role.
-func (db *DB) AddOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, user UserRef, role string) error {
+func (db *DB) AddOrgMember(ctx context.Context, actor Actor, org OrgRef, user UserRef, role string) error {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return err
@@ -375,7 +375,7 @@ func (db *DB) AddOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, use
}
// UpdateOrgMemberRole changes a member's role. Fails if demoting the last owner.
-func (db *DB) UpdateOrgMemberRole(ctx context.Context, actor uuid.UUID, org OrgRef, user UserRef, newRole string) error {
+func (db *DB) UpdateOrgMemberRole(ctx context.Context, actor Actor, org OrgRef, user UserRef, newRole string) error {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return err
@@ -426,7 +426,7 @@ func (db *DB) UpdateOrgMemberRole(ctx context.Context, actor uuid.UUID, org OrgR
}
// RemoveOrgMember removes a user from an org. Fails if they are the last owner.
-func (db *DB) RemoveOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, user UserRef) error {
+func (db *DB) RemoveOrgMember(ctx context.Context, actor Actor, org OrgRef, user UserRef) error {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return err
diff --git a/internal/database/user.go b/internal/database/user.go
index 972337d..a6d250c 100644
--- a/internal/database/user.go
+++ b/internal/database/user.go
@@ -21,15 +21,22 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"golang.org/x/crypto/argon2"
+
+ "dimidiumlabs/mirum/internal/config"
)
var (
- 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")
+ 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")
+ ErrReservedEmail = errors.New("database: email uses a reserved domain")
)
+// reservedEmailSuffix is the domain carved out for synthetic actors
+// (system/operator/anon). Real users cannot register with this suffix.
+const reservedEmailSuffix = "@mirum.local"
+
// UserRef identifies a user by ID or email.
type UserRef struct {
id uuid.UUID
@@ -53,8 +60,6 @@ const (
argonMemory = 64 * 1024 // 64 MB
argonKeyLen = 32
argonThreads = 2
-
- SessionTTL = 14 * 24 * time.Hour // 14 days
)
// User holds info about a user.
@@ -64,13 +69,6 @@ type User struct {
CreatedAt time.Time
}
-// Session holds info about an authenticated session.
-type Session struct {
- UserID uuid.UUID
- Email string
- Superuser bool
-}
-
// hashToken returns the hex-encoded SHA-256 of a session token.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
@@ -135,7 +133,11 @@ 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, actor uuid.UUID, email, password string, pepper []byte) (uuid.UUID, error) {
+func (db *DB) UserCreate(ctx context.Context, actor Actor, email, password string, pepper []byte) (uuid.UUID, error) {
+ if strings.HasSuffix(strings.ToLower(email), reservedEmailSuffix) {
+ return uuid.Nil, ErrReservedEmail
+ }
+
hash, err := hashPassword(password, pepper)
if err != nil {
return uuid.Nil, err
@@ -163,7 +165,7 @@ func (db *DB) UserCreate(ctx context.Context, actor uuid.UUID, email, password s
}
// GetUser returns a user by ref (ID or email).
-func (db *DB) GetUser(ctx context.Context, actor uuid.UUID, ref UserRef) (*User, error) {
+func (db *DB) GetUser(ctx context.Context, actor Actor, ref UserRef) (*User, error) {
col, val := ref.where()
tx, err := db.beginAs(ctx, actor)
@@ -190,7 +192,7 @@ func (db *DB) GetUser(ctx context.Context, actor uuid.UUID, ref UserRef) (*User,
}
// ListUsers returns a page of users and the total count.
-func (db *DB) ListUsers(ctx context.Context, actor uuid.UUID, cursor uuid.UUID, limit int, filter string) ([]User, int, error) {
+func (db *DB) ListUsers(ctx context.Context, actor Actor, cursor uuid.UUID, limit int, filter string) ([]User, int, error) {
if filter != "" {
return nil, 0, ErrFilterNotImplemented
}
@@ -281,10 +283,13 @@ func resolveUser(ctx context.Context, tx pgx.Tx, ref UserRef) (uuid.UUID, error)
// UserUpdate updates a user's email and/or password.
// Invalidates all sessions when password changes.
-func (db *DB) UserUpdate(ctx context.Context, actor uuid.UUID, ref UserRef, email *string, password *string, pepper []byte) error {
+func (db *DB) UserUpdate(ctx context.Context, actor Actor, ref UserRef, email *string, password *string, pepper []byte) error {
if email == nil && password == nil {
return nil
}
+ if email != nil && strings.HasSuffix(strings.ToLower(*email), reservedEmailSuffix) {
+ return ErrReservedEmail
+ }
tx, err := db.beginAs(ctx, actor)
if err != nil {
@@ -335,7 +340,7 @@ func (db *DB) UserUpdate(ctx context.Context, actor uuid.UUID, ref UserRef, emai
// UserDelete soft-deletes a user.
// Fails if the user is the sole owner of any organization.
-func (db *DB) UserDelete(ctx context.Context, actor uuid.UUID, ref UserRef) error {
+func (db *DB) UserDelete(ctx context.Context, actor Actor, ref UserRef) error {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return err
@@ -369,7 +374,7 @@ func (db *DB) UserDelete(ctx context.Context, actor uuid.UUID, ref UserRef) erro
}
// UserVerifyPassword checks credentials and returns the user ID.
-func (db *DB) UserVerifyPassword(ctx context.Context, actor uuid.UUID, email, password string, pepper []byte) (uuid.UUID, error) {
+func (db *DB) UserVerifyPassword(ctx context.Context, actor Actor, email, password string, pepper []byte) (uuid.UUID, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return uuid.Nil, err
@@ -393,41 +398,49 @@ func (db *DB) UserVerifyPassword(ctx context.Context, actor uuid.UUID, email, pa
return id, nil
}
-// UserGetSession returns session info for a valid, non-expired session.
-func (db *DB) UserGetSession(ctx context.Context, actor uuid.UUID, token string) (*Session, error) {
+// UserGetSession resolves a session token into the Actor it authenticates.
+// Returns an invalid zero Actor on any error; callers must check err.
+func (db *DB) UserGetSession(ctx context.Context, actor Actor, token string) (Actor, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
- return nil, err
+ return Actor{}, err
}
defer tx.Rollback(ctx)
- var s Session
+ var (
+ userID uuid.UUID
+ email string
+ superuser bool
+ expiresAt time.Time
+ )
h := hashToken(token)
- var expiresAt time.Time
if err := tx.QueryRow(ctx,
`SELECT s.user_id, u.email, u.superuser, 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, &s.Superuser, &expiresAt); err != nil {
- return nil, err
+ ).Scan(&userID, &email, &superuser, &expiresAt); err != nil {
+ return Actor{}, err
}
- if time.Until(expiresAt) < SessionTTL/2 {
+ if time.Until(expiresAt) < config.SessionTTL/2 {
if _, err := tx.Exec(ctx,
`UPDATE sessions SET expires_at = now() + $2 WHERE token = $1`,
- h, SessionTTL,
+ h, config.SessionTTL,
); err != nil {
- return nil, err
+ return Actor{}, err
}
}
- return &s, tx.Commit(ctx)
+ if err := tx.Commit(ctx); err != nil {
+ return Actor{}, err
+ }
+ return UserActor(userID, email, superuser), nil
}
// UserCreateSession generates a random token, stores its hash, and returns the token.
-func (db *DB) UserCreateSession(ctx context.Context, actor uuid.UUID, userID uuid.UUID) (string, error) {
+func (db *DB) UserCreateSession(ctx context.Context, actor Actor, userID uuid.UUID) (string, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return "", err
@@ -442,7 +455,7 @@ func (db *DB) UserCreateSession(ctx context.Context, actor uuid.UUID, userID uui
if _, err := tx.Exec(ctx,
`INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, now() + $3)`,
- hashToken(token), userID, SessionTTL,
+ hashToken(token), userID, config.SessionTTL,
); err != nil {
return "", err
}
@@ -451,7 +464,7 @@ func (db *DB) UserCreateSession(ctx context.Context, actor uuid.UUID, userID uui
}
// UserDeleteSession removes a session (logout).
-func (db *DB) UserDeleteSession(ctx context.Context, actor uuid.UUID, token string) error {
+func (db *DB) UserDeleteSession(ctx context.Context, actor Actor, token string) error {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return err
@@ -464,9 +477,9 @@ func (db *DB) UserDeleteSession(ctx context.Context, actor uuid.UUID, token stri
return tx.Commit(ctx)
}
-// PurgeExpiredSessions deletes all expired sessions. Runs as root.
+// PurgeExpiredSessions deletes all expired sessions. Runs as SystemActor.
func (db *DB) PurgeExpiredSessions(ctx context.Context) error {
- tx, err := db.beginAs(ctx, uuid.Nil)
+ tx, err := db.beginAs(ctx, SystemActor())
if err != nil {
return err
}
diff --git a/internal/database/worker.go b/internal/database/worker.go
index a92e6e6..3680901 100644
--- a/internal/database/worker.go
+++ b/internal/database/worker.go
@@ -26,7 +26,7 @@ type Worker struct {
}
// GetWorker returns a worker by ID.
-func (db *DB) GetWorker(ctx context.Context, actor uuid.UUID, id uuid.UUID) (*Worker, error) {
+func (db *DB) GetWorker(ctx context.Context, actor Actor, id uuid.UUID) (*Worker, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return nil, err
@@ -48,7 +48,7 @@ func (db *DB) GetWorker(ctx context.Context, actor uuid.UUID, id uuid.UUID) (*Wo
}
// CreateWorker registers a new worker with the given public key and optional org.
-func (db *DB) CreateWorker(ctx context.Context, actor uuid.UUID, publicKey []byte, org *OrgRef) (uuid.UUID, error) {
+func (db *DB) CreateWorker(ctx context.Context, actor Actor, publicKey []byte, org *OrgRef) (uuid.UUID, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return uuid.Nil, err
@@ -76,7 +76,7 @@ func (db *DB) CreateWorker(ctx context.Context, actor uuid.UUID, publicKey []byt
}
// DeleteWorker soft-deletes a worker by ID.
-func (db *DB) DeleteWorker(ctx context.Context, actor uuid.UUID, id uuid.UUID) error {
+func (db *DB) DeleteWorker(ctx context.Context, actor Actor, id uuid.UUID) error {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return err
@@ -97,7 +97,7 @@ func (db *DB) DeleteWorker(ctx context.Context, actor uuid.UUID, id uuid.UUID) e
}
// ListWorkers returns a page of workers and the total count.
-func (db *DB) ListWorkers(ctx context.Context, actor uuid.UUID, cursor uuid.UUID, limit int, filter string) ([]Worker, int, error) {
+func (db *DB) ListWorkers(ctx context.Context, actor Actor, cursor uuid.UUID, limit int, filter string) ([]Worker, int, error) {
if filter != "" {
return nil, 0, ErrFilterNotImplemented
}
@@ -144,7 +144,7 @@ func (db *DB) ListWorkers(ctx context.Context, actor uuid.UUID, cursor uuid.UUID
}
// LookupWorker finds an active worker by its ed25519 public key.
-func (db *DB) LookupWorker(ctx context.Context, actor uuid.UUID, publicKey []byte) (*Worker, error) {
+func (db *DB) LookupWorker(ctx context.Context, actor Actor, publicKey []byte) (*Worker, error) {
tx, err := db.beginAs(ctx, actor)
if err != nil {
return nil, err
diff --git a/internal/protocol/backoff.go b/internal/protocol/backoff.go
index dc53e60..5a3cf20 100644
--- a/internal/protocol/backoff.go
+++ b/internal/protocol/backoff.go
@@ -7,6 +7,8 @@ import (
"context"
"math/rand/v2"
"time"
+
+ "dimidiumlabs/mirum/internal/config"
)
type Backoff struct {
@@ -15,7 +17,7 @@ type Backoff struct {
}
func NewBackoff() *Backoff {
- return &Backoff{Min: time.Second, Max: 60 * time.Second}
+ return &Backoff{Min: config.WorkerBackoffMin, Max: config.WorkerBackoffMax}
}
func (b *Backoff) Reset() {
diff --git a/internal/protocol/handshake.go b/internal/protocol/handshake.go
index 5d020a2..204600a 100644
--- a/internal/protocol/handshake.go
+++ b/internal/protocol/handshake.go
@@ -15,6 +15,8 @@ import (
"net/url"
"os"
"time"
+
+ "dimidiumlabs/mirum/internal/config"
)
var (
@@ -108,7 +110,7 @@ func SelfSignedCert(key ed25519.PrivateKey, meta *WorkerMeta) (tls.Certificate,
tmpl := &x509.Certificate{
SerialNumber: serial,
NotBefore: now,
- NotAfter: now.Add(24 * time.Hour),
+ NotAfter: now.Add(config.WorkerCertLifetime),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
URIs: []*url.URL{meta.URI()},
diff --git a/proto/admin.proto b/proto/admin.proto
index 7f2231a..58f4e64 100644
--- a/proto/admin.proto
+++ b/proto/admin.proto
@@ -61,6 +61,53 @@ enum Perm {
PERM_WORKER_WRITE = 10;
}
+// ErrorInfo is attached as a ConnectError detail on every error.
+// The ConnectError message field is always empty — the server never sends
+// human-readable text. Clients switch on reason to select user-facing copy.
+message ErrorInfo {
+ ErrorReason reason = 1;
+
+ // Short stable identifiers for UI targeting (e.g. {"field": "slug"}).
+ // Never human-readable text.
+ map<string, string> metadata = 2;
+}
+
+enum ErrorReason {
+ ERROR_REASON_UNSPECIFIED = 0;
+ ERROR_REASON_INTERNAL = 1;
+
+ // Lookup failures
+ ERROR_REASON_USER_NOT_FOUND = 10;
+ ERROR_REASON_ORG_NOT_FOUND = 11;
+ ERROR_REASON_WORKER_NOT_FOUND = 12;
+ ERROR_REASON_MEMBER_NOT_FOUND = 13;
+
+ // Conflicts
+ ERROR_REASON_EMAIL_TAKEN = 20;
+ ERROR_REASON_SLUG_TAKEN = 21;
+ ERROR_REASON_ALREADY_MEMBER = 22;
+
+ // State preconditions
+ ERROR_REASON_LAST_OWNER = 30;
+ ERROR_REASON_SOLE_OWNER = 31;
+
+ // Validation
+ ERROR_REASON_INVALID_SLUG = 40;
+ ERROR_REASON_INVALID_ROLE = 41;
+ ERROR_REASON_RESERVED_EMAIL = 42;
+
+ // Auth
+ ERROR_REASON_UNAUTHENTICATED = 50;
+ ERROR_REASON_PERMISSION_DENIED = 51;
+ ERROR_REASON_INVALID_CREDENTIALS = 52;
+ ERROR_REASON_INVALID_CSRF = 53;
+
+ // Infrastructure
+ ERROR_REASON_RATE_LIMITED = 60;
+ ERROR_REASON_UNAVAILABLE = 61;
+ ERROR_REASON_UNIMPLEMENTED = 62;
+}
+
message UserRef {
oneof ref {
option (buf.validate.oneof).required = true;