more customer
This commit is contained in:
@@ -239,15 +239,13 @@ func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create checkout with same ID as cart
|
// The checkout grain is 1:1 with the cart it checks out (CheckoutId ==
|
||||||
var checkoutId checkout.CheckoutId = cart.MustNewCartId()
|
// CartId). Keying it by the cart id — not a lingering checkoutid cookie —
|
||||||
cookie, err := r.Cookie(checkoutCookieName)
|
// means a fresh cart always gets a fresh checkout, so the Klarna order
|
||||||
if err == nil {
|
// reflects the cart shown at /kassa. Reusing the cookie made a new cart's
|
||||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
// checkout latch onto a previous cart's frozen snapshot (InitializeCheckout
|
||||||
if ok {
|
// silently refuses to re-snapshot a grain that already has a CartState).
|
||||||
checkoutId = parsed
|
checkoutId := checkout.CheckoutId(cartId)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize checkout with cart state wrapped in Any
|
// Initialize checkout with cart state wrapped in Any
|
||||||
cartStateAny := &messages.InitializeCheckout{
|
cartStateAny := &messages.InitializeCheckout{
|
||||||
|
|||||||
+31
-1
@@ -2,20 +2,39 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/internal/customerauth"
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// authSecret returns the HMAC key for customer session cookies. It reads
|
||||||
|
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
|
||||||
|
// warns that issued sessions will not survive a restart (fine for dev).
|
||||||
|
func authSecret() []byte {
|
||||||
|
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
|
||||||
|
return []byte(v)
|
||||||
|
}
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
log.Fatalf("could not generate auth secret: %v", err)
|
||||||
|
}
|
||||||
|
log.Print("warning: CUSTOMER_AUTH_SECRET unset — using an ephemeral key; customer sessions will not survive a restart")
|
||||||
|
return []byte(hex.EncodeToString(b))
|
||||||
|
}
|
||||||
|
|
||||||
var podIp = os.Getenv("POD_IP")
|
var podIp = os.Getenv("POD_IP")
|
||||||
var name = os.Getenv("POD_NAME")
|
var name = os.Getenv("POD_NAME")
|
||||||
|
|
||||||
@@ -64,6 +83,9 @@ func main() {
|
|||||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||||
return ret, err
|
return ret, err
|
||||||
},
|
},
|
||||||
|
// Destroy is an optional grain-eviction cleanup hook; profiles need none
|
||||||
|
// (disk storage persists via its own loop), so it's left unset — purge()
|
||||||
|
// skips a nil Destroy.
|
||||||
TTL: 10 * time.Minute,
|
TTL: 10 * time.Minute,
|
||||||
PoolSize: 65535,
|
PoolSize: 65535,
|
||||||
Hostname: podIp,
|
Hostname: podIp,
|
||||||
@@ -96,6 +118,14 @@ func main() {
|
|||||||
// passes the full request path without stripping the prefix.
|
// passes the full request path without stripping the prefix.
|
||||||
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
||||||
|
|
||||||
|
// Customer auth: password signup/login + session cookies + identity linking.
|
||||||
|
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error loading credential store: %v\n", err)
|
||||||
|
}
|
||||||
|
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0).Handler()
|
||||||
|
mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler))
|
||||||
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
grainCount, capacity := pool.LocalUsage()
|
grainCount, capacity := pool.LocalUsage()
|
||||||
if grainCount >= capacity {
|
if grainCount >= capacity {
|
||||||
@@ -126,7 +156,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":8080",
|
Addr: getEnv("PROFILE_ADDR", ":8080"),
|
||||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 20 * time.Second,
|
WriteTimeout: 20 * time.Second,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package customerauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordRoundTrip(t *testing.T) {
|
||||||
|
h, err := HashPassword("correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
if !VerifyPassword("correct horse battery staple", h) {
|
||||||
|
t.Fatal("correct password did not verify")
|
||||||
|
}
|
||||||
|
if VerifyPassword("wrong", h) {
|
||||||
|
t.Fatal("wrong password verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordHashesAreSalted(t *testing.T) {
|
||||||
|
a, _ := HashPassword("hunter2hunter2")
|
||||||
|
b, _ := HashPassword("hunter2hunter2")
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("two hashes of the same password are identical — not salted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsMalformed(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", "x", "pbkdf2-sha256$abc$salt$hash", "md5$1$a$b"} {
|
||||||
|
if VerifyPassword("whatever", bad) {
|
||||||
|
t.Fatalf("malformed hash %q verified", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionRoundTrip(t *testing.T) {
|
||||||
|
s := NewSigner([]byte("test-secret"))
|
||||||
|
tok := s.Issue(12345, time.Hour)
|
||||||
|
id, err := s.Parse(tok)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if id != 12345 {
|
||||||
|
t.Fatalf("got id %d, want 12345", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionExpired(t *testing.T) {
|
||||||
|
s := NewSigner([]byte("test-secret"))
|
||||||
|
tok := s.Issue(1, -time.Second)
|
||||||
|
if _, err := s.Parse(tok); err != ErrExpiredToken {
|
||||||
|
t.Fatalf("got %v, want ErrExpiredToken", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTamperAndWrongKey(t *testing.T) {
|
||||||
|
s := NewSigner([]byte("test-secret"))
|
||||||
|
tok := s.Issue(7, time.Hour)
|
||||||
|
if _, err := s.Parse(tok + "x"); err != ErrInvalidToken {
|
||||||
|
t.Fatalf("tampered token: got %v, want ErrInvalidToken", err)
|
||||||
|
}
|
||||||
|
other := NewSigner([]byte("different-secret"))
|
||||||
|
if _, err := other.Parse(tok); err != ErrInvalidToken {
|
||||||
|
t.Fatalf("wrong key: got %v, want ErrInvalidToken", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||||
|
store, err := LoadCredentialStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Register("User@Example.com", 42, "hash1", "ts"); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
// Duplicate (case-insensitive) is rejected.
|
||||||
|
if err := store.Register("user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
|
||||||
|
t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
|
||||||
|
}
|
||||||
|
// Reload from disk and confirm the record persisted.
|
||||||
|
reloaded, err := LoadCredentialStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
rec, ok := reloaded.Get("USER@EXAMPLE.COM")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("record not found after reload")
|
||||||
|
}
|
||||||
|
if rec.ProfileID != 42 || rec.Hash != "hash1" {
|
||||||
|
t.Fatalf("unexpected record: %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Package customerauth adds password-based signup/login to the profile service.
|
||||||
|
//
|
||||||
|
// It deliberately uses only the standard library: PBKDF2-HMAC-SHA256 for
|
||||||
|
// password hashing (crypto/pbkdf2, Go 1.24+) and HMAC-SHA256-signed cookies for
|
||||||
|
// sessions (crypto/hmac). No password ever leaves the server in plaintext and
|
||||||
|
// the session token is opaque + tamper-evident.
|
||||||
|
package customerauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/pbkdf2"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pbkdf2Iterations is the work factor for PBKDF2-HMAC-SHA256. 210_000 matches
|
||||||
|
// the current OWASP recommendation for PBKDF2-SHA256.
|
||||||
|
const pbkdf2Iterations = 210_000
|
||||||
|
|
||||||
|
const (
|
||||||
|
saltLen = 16
|
||||||
|
keyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashPassword returns an encoded PBKDF2 hash of the form
|
||||||
|
//
|
||||||
|
// pbkdf2-sha256$<iterations>$<saltB64>$<hashB64>
|
||||||
|
//
|
||||||
|
// with a fresh random salt. The encoded string is self-describing so Verify can
|
||||||
|
// read the parameters back without external configuration.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
salt := make([]byte, saltLen)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("customerauth: read salt: %w", err)
|
||||||
|
}
|
||||||
|
dk, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, keyLen)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("customerauth: pbkdf2: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
|
||||||
|
pbkdf2Iterations,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(dk),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword reports whether password matches the encoded hash. It is
|
||||||
|
// constant-time in the hash comparison and returns false for any malformed
|
||||||
|
// encoding rather than erroring, so callers can treat it as a simple predicate.
|
||||||
|
func VerifyPassword(password, encoded string) bool {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
iter, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil || iter <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package customerauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minPasswordLen is the minimum accepted password length at registration.
|
||||||
|
const minPasswordLen = 8
|
||||||
|
|
||||||
|
// ProfileApplier is the subset of the profile grain pool the auth server needs.
|
||||||
|
// The pool (and the UCP adapter's ProfileApplier) already satisfies it.
|
||||||
|
type ProfileApplier interface {
|
||||||
|
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
|
||||||
|
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server exposes password signup/login and identity-linking over HTTP, backed
|
||||||
|
// by the credential store (email→id+hash) and the profile grain pool.
|
||||||
|
type Server struct {
|
||||||
|
store *CredentialStore
|
||||||
|
applier ProfileApplier
|
||||||
|
signer *Signer
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
|
||||||
|
func NewServer(store *CredentialStore, applier ProfileApplier, signer *Signer, ttl time.Duration) *Server {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = DefaultSessionTTL
|
||||||
|
}
|
||||||
|
return &Server{store: store, applier: applier, signer: signer, ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
|
||||||
|
// (the prefix is stripped by the caller).
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /register", s.handleRegister)
|
||||||
|
mux.HandleFunc("POST /login", s.handleLogin)
|
||||||
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("GET /me", s.handleMe)
|
||||||
|
mux.HandleFunc("POST /link-cart", s.handleLinkCart)
|
||||||
|
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
|
||||||
|
mux.HandleFunc("POST /link-order", s.handleLinkOrder)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request/response payloads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkCartRequest struct {
|
||||||
|
CartID string `json:"cartId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkCheckoutRequest struct {
|
||||||
|
CheckoutID string `json:"checkoutId"`
|
||||||
|
CartID string `json:"cartId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkOrderRequest struct {
|
||||||
|
OrderReference string `json:"orderReference"`
|
||||||
|
CartID string `json:"cartId"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
||||||
|
// import cycle with internal/ucp). The password hash is never included.
|
||||||
|
type CustomerResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Currency string `json:"currency,omitempty"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
|
Addresses []AddressResponse `json:"addresses"`
|
||||||
|
Orders []OrderRef `json:"orders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddressResponse struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
FullName string `json:"fullName,omitempty"`
|
||||||
|
AddressLine1 string `json:"addressLine1"`
|
||||||
|
AddressLine2 string `json:"addressLine2,omitempty"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Zip string `json:"zip"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
|
||||||
|
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderRef struct {
|
||||||
|
OrderReference string `json:"orderReference"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req registerRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := NormalizeEmail(req.Email)
|
||||||
|
if !validEmail(email) {
|
||||||
|
writeErr(w, http.StatusBadRequest, "a valid email is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Password) < minPasswordLen {
|
||||||
|
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := s.store.Get(email); exists {
|
||||||
|
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawID, err := profile.NewProfileId()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not generate id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := uint64(rawID)
|
||||||
|
|
||||||
|
set := &messages.SetProfile{Email: &email}
|
||||||
|
if req.Name != "" {
|
||||||
|
set.Name = &req.Name
|
||||||
|
}
|
||||||
|
if req.Phone != "" {
|
||||||
|
set.Phone = &req.Phone
|
||||||
|
}
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, set); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not create profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the credential only after the profile is created. If this fails
|
||||||
|
// the profile exists but is unreachable by login — acceptable and rare.
|
||||||
|
if err := s.store.Register(email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
if err == ErrEmailExists {
|
||||||
|
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not store credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.issueSession(w, r, id)
|
||||||
|
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, ok := s.store.Get(req.Email)
|
||||||
|
// Always run a verify (even on miss, against the stored hash if present) and
|
||||||
|
// return a single generic error so the response does not reveal whether the
|
||||||
|
// email exists.
|
||||||
|
if !ok || !VerifyPassword(req.Password, rec.Hash) {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.issueSession(w, r, rec.ProfileID)
|
||||||
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ClearSession(w, r)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := s.requireSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := s.requireSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkCartRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartID, ok := parseBase62(req.CartID)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid cartId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCart{CartId: cartID, Label: "current cart"}); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not link cart")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLinkCheckout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := s.requireSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkCheckoutRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkoutID, ok := parseBase62(req.CheckoutID)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid checkoutId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartID, _ := parseBase62(req.CartID)
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCheckout{CheckoutId: checkoutID, CartId: cartID}); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not link checkout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLinkOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := s.requireSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkOrderRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.OrderReference) == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "orderReference is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartID, _ := parseBase62(req.CartID)
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkOrder{
|
||||||
|
OrderReference: req.OrderReference,
|
||||||
|
CartId: cartID,
|
||||||
|
Status: req.Status,
|
||||||
|
}); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not link order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||||
|
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireSession reads and validates the session cookie, writing 401 on
|
||||||
|
// failure. It returns the profile id and whether a valid session was present.
|
||||||
|
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
||||||
|
c, err := r.Cookie(SessionCookieName)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id, err := s.signer.Parse(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCustomer loads the profile grain and writes it as a CustomerResponse.
|
||||||
|
func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status int, idStr string, id uint64) {
|
||||||
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, status, grainToCustomer(idStr, g))
|
||||||
|
}
|
||||||
|
|
||||||
|
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
||||||
|
resp := CustomerResponse{
|
||||||
|
ID: id,
|
||||||
|
Name: g.Name,
|
||||||
|
Email: g.Email,
|
||||||
|
Phone: g.Phone,
|
||||||
|
Language: g.Language,
|
||||||
|
Currency: g.Currency,
|
||||||
|
AvatarURL: g.AvatarUrl,
|
||||||
|
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
|
||||||
|
Orders: make([]OrderRef, 0, len(g.Orders)),
|
||||||
|
}
|
||||||
|
for _, a := range g.Addresses {
|
||||||
|
resp.Addresses = append(resp.Addresses, AddressResponse{
|
||||||
|
ID: a.Id,
|
||||||
|
Label: a.Label,
|
||||||
|
FullName: a.FullName,
|
||||||
|
AddressLine1: a.AddressLine1,
|
||||||
|
AddressLine2: a.AddressLine2,
|
||||||
|
City: a.City,
|
||||||
|
Zip: a.Zip,
|
||||||
|
Country: a.Country,
|
||||||
|
IsDefaultShipping: a.IsDefaultShipping,
|
||||||
|
IsDefaultBilling: a.IsDefaultBilling,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, o := range g.Orders {
|
||||||
|
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBase62 decodes a base62 id (cart/checkout/profile share the scheme).
|
||||||
|
func parseBase62(s string) (uint64, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id, ok := profile.ParseProfileId(s)
|
||||||
|
return uint64(id), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEmail(email string) bool {
|
||||||
|
if email == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := mail.ParseAddress(email)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package customerauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionCookieName is the name of the HttpOnly cookie that carries the signed
|
||||||
|
// session token for a logged-in customer.
|
||||||
|
const SessionCookieName = "sid"
|
||||||
|
|
||||||
|
// DefaultSessionTTL is how long an issued session stays valid.
|
||||||
|
const DefaultSessionTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidToken is returned when a token is malformed or its signature
|
||||||
|
// does not verify.
|
||||||
|
ErrInvalidToken = errors.New("customerauth: invalid session token")
|
||||||
|
// ErrExpiredToken is returned when a token's signature is valid but it has
|
||||||
|
// passed its expiry.
|
||||||
|
ErrExpiredToken = errors.New("customerauth: expired session token")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signer issues and verifies session tokens using an HMAC-SHA256 key.
|
||||||
|
type Signer struct {
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSigner returns a Signer over the given secret key bytes.
|
||||||
|
func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} }
|
||||||
|
|
||||||
|
// token format: base64url(<profileID>.<expUnix>) "." base64url(hmac)
|
||||||
|
// The payload is signed, not encrypted — it carries no secrets, only the
|
||||||
|
// profile id and expiry, and the HMAC makes it tamper-evident.
|
||||||
|
|
||||||
|
// Issue returns a signed token for profileID that expires after ttl.
|
||||||
|
func (s *Signer) Issue(profileID uint64, ttl time.Duration) string {
|
||||||
|
exp := time.Now().Add(ttl).Unix()
|
||||||
|
payload := fmt.Sprintf("%d.%d", profileID, exp)
|
||||||
|
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||||
|
return b + "." + s.sign(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse verifies a token and returns the profile id it carries. It returns
|
||||||
|
// ErrInvalidToken for a bad signature/format and ErrExpiredToken when expired.
|
||||||
|
func (s *Signer) Parse(token string) (uint64, error) {
|
||||||
|
b, sig, ok := strings.Cut(token, ".")
|
||||||
|
if !ok || b == "" || sig == "" {
|
||||||
|
return 0, ErrInvalidToken
|
||||||
|
}
|
||||||
|
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
|
||||||
|
return 0, ErrInvalidToken
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(b)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrInvalidToken
|
||||||
|
}
|
||||||
|
idStr, expStr, ok := strings.Cut(string(raw), ".")
|
||||||
|
if !ok {
|
||||||
|
return 0, ErrInvalidToken
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrInvalidToken
|
||||||
|
}
|
||||||
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrInvalidToken
|
||||||
|
}
|
||||||
|
if time.Now().Unix() >= exp {
|
||||||
|
return 0, ErrExpiredToken
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Signer) sign(b string) string {
|
||||||
|
mac := hmac.New(sha256.New, s.secret)
|
||||||
|
mac.Write([]byte(b))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSession writes the session cookie for token onto w. The Secure flag is set
|
||||||
|
// when the request arrived over TLS (directly or via an https-terminating
|
||||||
|
// proxy) so the cookie still works on plain-http localhost in dev.
|
||||||
|
func SetSession(w http.ResponseWriter, r *http.Request, token string, ttl time.Duration) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: SessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(ttl),
|
||||||
|
MaxAge: int(ttl.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSession expires the session cookie on w.
|
||||||
|
func ClearSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: SessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSecure(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package customerauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrEmailExists is returned by Register when the (normalized) email is already
|
||||||
|
// associated with a credential record.
|
||||||
|
var ErrEmailExists = errors.New("customerauth: email already registered")
|
||||||
|
|
||||||
|
// Record is a single stored credential: which profile an email belongs to and
|
||||||
|
// its password hash. It deliberately does not embed any profile data — the
|
||||||
|
// profile grain remains the source of truth for that.
|
||||||
|
type Record struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
ProfileID uint64 `json:"profileId"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialStore is a small email→credential index persisted to a JSON file.
|
||||||
|
// It is the email lookup that the event-sourced profile grains intentionally
|
||||||
|
// lack. Concurrency-safe; writes are atomic (temp file + rename).
|
||||||
|
type CredentialStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
path string
|
||||||
|
byMail map[string]Record // key: normalized email
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCredentialStore opens (or initializes) the store backed by path. A
|
||||||
|
// missing file is treated as an empty store; the file is created on first write.
|
||||||
|
func LoadCredentialStore(path string) (*CredentialStore, error) {
|
||||||
|
s := &CredentialStore{path: path, byMail: make(map[string]Record)}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("customerauth: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var records []Record
|
||||||
|
if len(data) > 0 {
|
||||||
|
if err := json.Unmarshal(data, &records); err != nil {
|
||||||
|
return nil, fmt.Errorf("customerauth: parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range records {
|
||||||
|
s.byMail[NormalizeEmail(r.Email)] = r
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeEmail lower-cases and trims an email for use as a lookup key.
|
||||||
|
func NormalizeEmail(email string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the record for email and whether it exists.
|
||||||
|
func (s *CredentialStore) Get(email string) (Record, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
r, ok := s.byMail[NormalizeEmail(email)]
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a new credential record and persists the store. It returns
|
||||||
|
// ErrEmailExists if the email is already taken.
|
||||||
|
func (s *CredentialStore) Register(email string, profileID uint64, hash, createdAt string) error {
|
||||||
|
key := NormalizeEmail(email)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, exists := s.byMail[key]; exists {
|
||||||
|
return ErrEmailExists
|
||||||
|
}
|
||||||
|
s.byMail[key] = Record{Email: key, ProfileID: profileID, Hash: hash, CreatedAt: createdAt}
|
||||||
|
return s.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistLocked writes the whole store to disk atomically. Caller holds s.mu.
|
||||||
|
func (s *CredentialStore) persistLocked() error {
|
||||||
|
records := make([]Record, 0, len(s.byMail))
|
||||||
|
for _, r := range s.byMail {
|
||||||
|
records = append(records, r)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(records, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("customerauth: marshal store: %w", err)
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(s.path); dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("customerauth: mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".credentials-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("customerauth: temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("customerauth: write temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("customerauth: close temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, s.path); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("customerauth: rename temp: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ type GrainPoolConfig[V any] struct {
|
|||||||
Hostname string
|
Hostname string
|
||||||
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
||||||
SpawnHost func(host string) (Host[V], error)
|
SpawnHost func(host string) (Host[V], error)
|
||||||
|
// Destroy is an optional cleanup hook called when a grain is evicted on TTL
|
||||||
|
// (e.g. to detach inventory subscriptions wired into its mutations). Leave
|
||||||
|
// nil when there is nothing to clean up — purge() skips a nil Destroy.
|
||||||
Destroy func(grain Grain[V]) error
|
Destroy func(grain Grain[V]) error
|
||||||
TTL time.Duration
|
TTL time.Duration
|
||||||
PoolSize int
|
PoolSize int
|
||||||
@@ -96,9 +99,11 @@ func (p *SimpleGrainPool[V]) purge() {
|
|||||||
for id, grain := range p.grains {
|
for id, grain := range p.grains {
|
||||||
if grain.GetLastAccess().Before(purgeLimit) {
|
if grain.GetLastAccess().Before(purgeLimit) {
|
||||||
purgedIds = append(purgedIds, id)
|
purgedIds = append(purgedIds, id)
|
||||||
|
if p.destroy != nil {
|
||||||
if err := p.destroy(grain); err != nil {
|
if err := p.destroy(grain); err != nil {
|
||||||
log.Printf("failed to destroy grain %d: %v", id, err)
|
log.Printf("failed to destroy grain %d: %v", id, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
delete(p.grains, id)
|
delete(p.grains, id)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user