diff --git a/internal/products/chat.go b/internal/products/chat.go
new file mode 100644
index 0000000..26a2a97
--- /dev/null
+++ b/internal/products/chat.go
@@ -0,0 +1,80 @@
+package products
+
+import (
+ "encoding/json"
+ "strconv"
+ "strings"
+)
+
+// outfitCommands are the chat commands whose arguments are product IDs: putting products on and taking them off.
+var outfitCommands = map[string]bool{
+ "*use": true, "*putOn": true, "*putOnOutfit": true, "*remove": true, "*takeOff": true,
+}
+
+// bodyProduct is the avatar body every full outfit starts from; a *use list with it is a whole outfit (the client
+// re-sends its outfit that way every 30 s or so).
+const bodyProduct = 80
+
+// strippedCommand is a chat line with community products taken out of its outfit command.
+type strippedCommand struct {
+ text string // the line to send instead
+ changed bool // products were taken out
+ empty bool // nothing is left to send
+ outfit bool // the line was a whole outfit (*use with the body)
+ products []int64 // the community products it named
+}
+
+// stripCommand takes products for which isCommunity is true out of an outfit command's arguments.
+func stripCommand(text string, isCommunity func(int64) bool) strippedCommand {
+ fields := strings.Fields(text)
+ if len(fields) == 0 || !outfitCommands[fields[0]] {
+ return strippedCommand{text: text}
+ }
+ r := strippedCommand{text: text}
+ kept := []string{fields[0]}
+ for _, f := range fields[1:] {
+ id, err := strconv.ParseInt(f, 10, 64)
+ if err == nil && id == bodyProduct && fields[0] == "*use" {
+ r.outfit = true
+ }
+ if err == nil && isCommunity(id) {
+ r.products = append(r.products, id)
+ continue
+ }
+ kept = append(kept, f)
+ }
+ if len(r.products) == 0 {
+ return r
+ }
+ r.changed = true
+ r.empty = len(kept) == 1
+ r.text = strings.Join(kept, " ")
+ return r
+}
+
+// chatMessage is the JSON the client sends on a chat queue's "messages" mount.
+type chatMessage struct {
+ fields map[string]json.RawMessage
+ text string
+ userID int64
+}
+
+func parseChatMessage(b []byte) (*chatMessage, bool) {
+ var fields map[string]json.RawMessage
+ if json.Unmarshal(b, &fields) != nil {
+ return nil, false
+ }
+ m := &chatMessage{fields: fields}
+ if json.Unmarshal(fields["message"], &m.text) != nil {
+ return nil, false
+ }
+ json.Unmarshal(fields["userId"], &m.userID)
+ return m, true
+}
+
+func (m *chatMessage) withText(text string) []byte {
+ b, _ := json.Marshal(text)
+ m.fields["message"] = b
+ out, _ := json.Marshal(m.fields)
+ return out
+}
diff --git a/internal/products/chat_test.go b/internal/products/chat_test.go
new file mode 100644
index 0000000..9d67fee
--- /dev/null
+++ b/internal/products/chat_test.go
@@ -0,0 +1,46 @@
+package products
+
+import (
+ "fmt"
+ "testing"
+)
+
+func community(id int64) bool { return id >= 2000000000 }
+
+func TestStripCommand(t *testing.T) {
+ for _, c := range []struct {
+ in, out string
+ empty, outfit bool
+ products string
+ }{
+ {"*use 80 123 2000000001 456", "*use 80 123 456", false, true, "[2000000001]"},
+ {"*use 2000000001", "*use", true, false, "[2000000001]"},
+ {"*putOnOutfit 5 2000000002 2000000003", "*putOnOutfit 5", false, false, "[2000000002 2000000003]"},
+ {"*remove 2000000001", "*remove", true, false, "[2000000001]"},
+ {"*use 80 123", "*use 80 123", false, true, "[]"},
+ {"hello 2000000001", "hello 2000000001", false, false, "[]"},
+ {"*imvu:setRoomState c...", "*imvu:setRoomState c...", false, false, "[]"},
+ } {
+ r := stripCommand(c.in, community)
+ if r.text != c.out || r.empty != c.empty || r.outfit != c.outfit || fmt.Sprint(r.products) != c.products {
+ t.Errorf("%q: got %+v", c.in, r)
+ }
+ if r.changed != (len(r.products) > 0) {
+ t.Errorf("%q: changed %v", c.in, r.changed)
+ }
+ }
+}
+
+func TestChatMessage(t *testing.T) {
+ m, ok := parseChatMessage([]byte(`{"to": 0, "message": "*use 80 2000000001", "userId": 392, "chatId": 77}`))
+ if !ok || m.text != "*use 80 2000000001" || m.userID != 392 {
+ t.Fatalf("%+v %v", m, ok)
+ }
+ out := string(m.withText("*use 80"))
+ if out != `{"chatId":77,"message":"*use 80","to":0,"userId":392}` {
+ t.Fatal(out)
+ }
+ if _, ok := parseChatMessage([]byte(`not json`)); ok {
+ t.Fatal("parsed non-JSON")
+ }
+}
diff --git a/internal/products/sign.go b/internal/products/sign.go
new file mode 100644
index 0000000..a637aad
--- /dev/null
+++ b/internal/products/sign.go
@@ -0,0 +1,68 @@
+package products
+
+import (
+ "crypto/md5"
+ "encoding/hex"
+ "net/http"
+ "regexp"
+ "sync"
+)
+
+// IMVU signs the client's requests: X-imvu-auth is md5(user ID + session security key + body), with the query
+// string added for some calls. The proxy re-signs requests it changes (taking community products out), with the key
+// from the login response. The key never leaves this machine.
+
+func sign(user, key string, body []byte, query string, withQuery bool) string {
+ h := md5.New()
+ h.Write([]byte(user + key))
+ h.Write(body)
+ if withQuery {
+ h.Write([]byte(query))
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// keys holds each signed-in user's security key.
+type keys struct {
+ mu sync.Mutex
+ byID map[string]string
+}
+
+var securityKeyPattern = regexp.MustCompile(`securityKey\s*(?:)?([0-9A-Za-z]+)`)
+var userIDPattern = regexp.MustCompile(`userId\s*(?:||)?([0-9]+)`)
+
+// learn takes the user's key from a login response (test.avatarInfoForLogin2).
+func (k *keys) learn(body []byte) {
+ key, user := securityKeyPattern.FindSubmatch(body), userIDPattern.FindSubmatch(body)
+ if key == nil || user == nil {
+ return
+ }
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ if k.byID == nil {
+ k.byID = map[string]string{}
+ }
+ k.byID[string(user[1])] = string(key[1])
+}
+
+// resign recomputes X-imvu-auth for a request whose body or query changed, in the scheme the original used. It
+// reports false if it couldn't (no key, or the original signature didn't match either scheme).
+func (k *keys) resign(h http.Header, oldBody []byte, oldQuery string, newBody []byte, newQuery string) bool {
+ auth, user := h.Get("X-imvu-auth"), h.Get("X-imvu-userid")
+ if auth == "" {
+ return true // not signed
+ }
+ k.mu.Lock()
+ key, ok := k.byID[user]
+ k.mu.Unlock()
+ if !ok {
+ return false
+ }
+ for _, withQuery := range []bool{false, true} {
+ if sign(user, key, oldBody, oldQuery, withQuery) == auth {
+ h.Set("X-imvu-auth", sign(user, key, newBody, newQuery, withQuery))
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/products/state.go b/internal/products/state.go
new file mode 100644
index 0000000..45467c6
--- /dev/null
+++ b/internal/products/state.go
@@ -0,0 +1,119 @@
+package products
+
+import (
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+)
+
+// FirstLocalID is where the client's numbers for community products start. IMVU's product IDs are far below
+// (about 72 million in 2026), and the client's XML-RPC can't carry numbers past 2^31-1.
+const FirstLocalID = 2_000_000_000
+
+// state is what the proxy remembers between runs, in /products.json: the local ID of every product it
+// has served (so outfits keep naming the same product), and the community products each IMVU account wears (IMVU
+// never sees them, so it can't remember them).
+type state struct {
+ mu sync.Mutex
+ path string
+ data stateData
+}
+
+type stateData struct {
+ IDs map[string]int64 `json:"ids"` // product ID -> local ID
+ Worn map[string][]string `json:"worn"` // IMVU user ID -> product IDs
+}
+
+func loadState(path string) (*state, error) {
+ s := &state{path: path, data: stateData{IDs: map[string]int64{}, Worn: map[string][]string{}}}
+ b, err := os.ReadFile(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return s, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(b, &s.data); err != nil {
+ return nil, err
+ }
+ if s.data.IDs == nil {
+ s.data.IDs = map[string]int64{}
+ }
+ if s.data.Worn == nil {
+ s.data.Worn = map[string][]string{}
+ }
+ return s, nil
+}
+
+// localID is the product's local ID, given one if it has none yet.
+func (s *state) localID(product string) (int64, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if id, ok := s.data.IDs[product]; ok {
+ return id, nil
+ }
+ next := int64(FirstLocalID)
+ for _, id := range s.data.IDs {
+ next = max(next, id+1)
+ }
+ if next > 2_147_483_647 {
+ return 0, errors.New("products: out of local IDs")
+ }
+ s.data.IDs[product] = next
+ return next, s.saveLocked()
+}
+
+// worn is the community products the user wears.
+func (s *state) worn(user string) []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]string(nil), s.data.Worn[user]...)
+}
+
+// setWorn records the community products the user wears.
+func (s *state) setWorn(user string, products []string) error {
+ products = append([]string(nil), products...)
+ sort.Strings(products)
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if equal(s.data.Worn[user], products) {
+ return nil
+ }
+ if len(products) == 0 {
+ delete(s.data.Worn, user)
+ } else {
+ s.data.Worn[user] = products
+ }
+ return s.saveLocked()
+}
+
+func equal(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func (s *state) saveLocked() error {
+ b, err := json.MarshalIndent(s.data, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
+ return err
+ }
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}