Embed the userservice and add serve/agent subcommands for one-binary operation
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain validation errors. They describe an entity that cannot be constructed,
|
||||
// independent of storage or transport, and the HTTP layer maps them to 400.
|
||||
var (
|
||||
ErrEmptyEmail = errors.New("email is required")
|
||||
ErrInvalidEmail = errors.New("email is not a valid address")
|
||||
ErrEmptyPasswordHash = errors.New("password hash is required")
|
||||
|
||||
ErrWorkerKeyNameTooLong = errors.New("worker key name is too long")
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
func (r Role) Valid() bool {
|
||||
switch r {
|
||||
case RoleAdmin, RoleUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
// Verified marks a trusted contributor whose workers' results the
|
||||
// coordinator accepts without quorum. Distinct from Role; granted by an
|
||||
// admin, defaults to false.
|
||||
Verified bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewUser builds a freshly registered account. It normalises the email and
|
||||
// enforces every invariant a row must satisfy, so an invalid User cannot be
|
||||
// constructed. The caller supplies the already-hashed password — hashing is an
|
||||
// adapter's job, not the domain's.
|
||||
//
|
||||
// Registration always produces a plain user; promotion to admin is a manual,
|
||||
// out-of-band operation, never something a request can trigger.
|
||||
func NewUser(email, passwordHash string, now time.Time) (*User, error) {
|
||||
email = NormalizeEmail(email)
|
||||
if err := validateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if passwordHash == "" {
|
||||
return nil, ErrEmptyPasswordHash
|
||||
}
|
||||
return &User{
|
||||
ID: uuid.New(),
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Role: RoleUser,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NormalizeEmail lower-cases and trims an address so that "Bob@X.com " and
|
||||
// "bob@x.com" resolve to the same account. Every lookup and every insert must
|
||||
// pass through here, matching the ck_users_email_lower database constraint.
|
||||
func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func validateEmail(email string) error {
|
||||
if email == "" {
|
||||
return ErrEmptyEmail
|
||||
}
|
||||
// A minimal shape check, not full RFC 5322: real deliverability is proven by
|
||||
// sending mail, not by a regex. mail.ParseAddress also accepts the
|
||||
// "Name <addr>" form, so we insist the parsed address equals the input.
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil || addr.Address != email {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestNewUserNormalisesAndValidates(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
u, err := NewUser(" Bob@Example.COM ", "hashed", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.Email != "bob@example.com" {
|
||||
t.Errorf("email not normalised: got %q", u.Email)
|
||||
}
|
||||
if u.Role != RoleUser {
|
||||
t.Errorf("new user must default to RoleUser, got %q", u.Role)
|
||||
}
|
||||
if u.ID == uuid.Nil {
|
||||
t.Error("new user must get an id")
|
||||
}
|
||||
if !u.CreatedAt.Equal(now) || !u.UpdatedAt.Equal(now) {
|
||||
t.Error("timestamps not set from clock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUserRejectsBadInput(t *testing.T) {
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
hash string
|
||||
wantErr error
|
||||
}{
|
||||
{"empty email", "", "h", ErrEmptyEmail},
|
||||
{"no domain", "bob", "h", ErrInvalidEmail},
|
||||
{"name form", "Bob <bob@x.com>", "h", ErrInvalidEmail},
|
||||
{"empty hash", "bob@x.com", "", ErrEmptyPasswordHash},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewUser(tc.email, tc.hash, now)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Errorf("got %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleValid(t *testing.T) {
|
||||
if !RoleUser.Valid() || !RoleAdmin.Valid() {
|
||||
t.Error("user and admin must be valid")
|
||||
}
|
||||
if Role("root").Valid() {
|
||||
t.Error("unknown role must be invalid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
// workerKeyLabel makes a key self-describing when it turns up in a log or an
|
||||
// env var, and lets a client sanity-check the shape before exchanging it.
|
||||
workerKeyLabel = "scimesh_wk_live_"
|
||||
// workerKeyRandomBytes is the entropy behind the secret. 24 bytes (192 bits)
|
||||
// is far beyond guessable, which is why the stored hash needs no salt.
|
||||
workerKeyRandomBytes = 24
|
||||
// workerKeyPrefixChars is how much of the random tail we keep, alongside the
|
||||
// label, as the non-secret identifier shown in the UI.
|
||||
workerKeyPrefixChars = 8
|
||||
// workerKeyNameMax caps the user-supplied label.
|
||||
workerKeyNameMax = 100
|
||||
// workerKeyDefaultName is used when the caller supplies no label.
|
||||
workerKeyDefaultName = "my machine"
|
||||
)
|
||||
|
||||
// WorkerKey is a long-lived, per-user credential for running a worker. The
|
||||
// secret itself is never stored — only TokenHash — so the plaintext returned by
|
||||
// NewWorkerKey is the one and only chance to show it to the user.
|
||||
type WorkerKey struct {
|
||||
ID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
TokenHash string
|
||||
Prefix string
|
||||
CreatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
}
|
||||
|
||||
// NewWorkerKey mints a key for a user and returns both the entity (carrying only
|
||||
// the hash) and the one-time plaintext to hand back to the caller. The label is
|
||||
// trimmed and defaulted; an over-long one is rejected.
|
||||
func NewWorkerKey(userID uuid.UUID, name string, now time.Time) (*WorkerKey, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = workerKeyDefaultName
|
||||
}
|
||||
if len(name) > workerKeyNameMax {
|
||||
return nil, "", ErrWorkerKeyNameTooLong
|
||||
}
|
||||
|
||||
b := make([]byte, workerKeyRandomBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// URL-safe, unpadded: the key rides in env vars and shell commands, so it
|
||||
// must contain no '=', '+', or '/' that a shell might mangle.
|
||||
raw := workerKeyLabel + base64.RawURLEncoding.EncodeToString(b)
|
||||
|
||||
key := &WorkerKey{
|
||||
ID: uuid.New(),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
TokenHash: HashWorkerKey(raw),
|
||||
Prefix: raw[:len(workerKeyLabel)+workerKeyPrefixChars],
|
||||
CreatedAt: now,
|
||||
}
|
||||
return key, raw, nil
|
||||
}
|
||||
|
||||
// HashWorkerKey returns the hex SHA-256 of a presented key. Exchange hashes the
|
||||
// incoming key the same way and looks the row up by it, so the plaintext never
|
||||
// has to be compared directly.
|
||||
func HashWorkerKey(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Revoked reports whether the key has been retired and must no longer exchange.
|
||||
func (k *WorkerKey) Revoked() bool { return k.RevokedAt != nil }
|
||||
@@ -0,0 +1,62 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
func TestNewWorkerKeyShape(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
key, raw, err := domain.NewWorkerKey(owner, "home-desktop", now)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkerKey: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(raw, "scimesh_wk_live_") {
|
||||
t.Errorf("raw key has no recognisable label: %q", raw)
|
||||
}
|
||||
if key.TokenHash != domain.HashWorkerKey(raw) {
|
||||
t.Error("stored hash does not match the plaintext")
|
||||
}
|
||||
if key.TokenHash == raw || strings.Contains(key.TokenHash, raw) {
|
||||
t.Error("plaintext leaked into the stored hash")
|
||||
}
|
||||
if !strings.HasPrefix(raw, key.Prefix) {
|
||||
t.Errorf("prefix %q is not a leading slice of the key", key.Prefix)
|
||||
}
|
||||
if key.UserID != owner || key.CreatedAt != now || key.Revoked() {
|
||||
t.Errorf("unexpected key metadata: %+v", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkerKeyDefaultsBlankName(t *testing.T) {
|
||||
key, _, err := domain.NewWorkerKey(uuid.New(), " ", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkerKey: %v", err)
|
||||
}
|
||||
if key.Name == "" {
|
||||
t.Error("blank name was not defaulted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkerKeyRejectsLongName(t *testing.T) {
|
||||
_, _, err := domain.NewWorkerKey(uuid.New(), strings.Repeat("x", 101), time.Now())
|
||||
if !errors.Is(err, domain.ErrWorkerKeyNameTooLong) {
|
||||
t.Errorf("got %v, want ErrWorkerKeyNameTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkerKeyUniquePerCall(t *testing.T) {
|
||||
a, rawA, _ := domain.NewWorkerKey(uuid.New(), "a", time.Now())
|
||||
b, rawB, _ := domain.NewWorkerKey(uuid.New(), "b", time.Now())
|
||||
if rawA == rawB || a.TokenHash == b.TokenHash || a.ID == b.ID {
|
||||
t.Error("two keys collided; generation is not random")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user