main.mx raw

   1  package main
   2  
   3  // MLS Worker - marmot (MLS-over-Nostr) messaging.
   4  //
   5  // Owns marmot state: KeyPairPackage, group states, peer key package cache.
   6  // Communicates through supervisor (wasm-host.mjs) via typed JSON messages.
   7  //
   8  // supervisor -> worker:
   9  //   ["M_SET_PUBKEY", pk]
  10  //   ["M_INIT", urlsJSON]                          -- generate/load KPP, load groups, subscribe
  11  //   ["M_SEND_DM", peer, content, urlsJSON]        -- send MLS DM
  12  //   ["M_INCOMING", evJSON]                        -- raw kind 443/445/1059 from fetch worker
  13  //   ["M_CRYPTO_RESULT", reqID, result, err]       -- signer response
  14  //   ["M_STORE_RESULT", tag, reqID, data]          -- store response
  15  //   ["M_FETCH_KP_RESULT", peer, evJSON]           -- key package fetch result
  16  //
  17  // worker -> supervisor:
  18  //   ["M_CRYPTO_REQ", reqID, method, peer, data]  -- to signer
  19  //   ["M_STORE_REQ", tag, reqID, ...]             -- to store worker
  20  //   ["M_PUBLISH", evJSON, urlsJSON]              -- publish via relay-proxy
  21  //   ["M_FETCH_KP", peer, urlsJSON]              -- request key package from fetch worker
  22  //   ["M_SUBSCRIBE", urlsJSON, groupIDsJSON]     -- trigger fetch worker subscription
  23  //   ["M_UPDATE_GROUPS", groupIDsJSON]           -- update fetch worker kind 445 filter
  24  //   ["MLS_DM_RECEIVED", dmJSON]                -- to app worker
  25  //   ["MLS_DM_SENT", peer, ok, err]             -- to app worker
  26  //   ["MLS_UNREAD_COUNT", count]                -- to app worker
  27  //   ["MLS_DM_LIST", listJSON]                  -- to app worker
  28  //   ["MLS_DM_HISTORY", peer, msgsJSON]         -- to app worker
  29  
  30  import (
  31  	"runtime"
  32  	"git.smesh.lol/musiquay/web/common/helpers"
  33  	"git.smesh.lol/musiquay/web/common/jsbridge/mlsw"
  34  	"git.smesh.lol/musiquay/web/common/marmot"
  35  	"git.smesh.lol/musiquay/web/common/mls"
  36  	"git.smesh.lol/musiquay/web/common/mw"
  37  	"git.smesh.lol/nostr/pkg/core"
  38  )
  39  
  40  
  41  type pendingSend struct {
  42  	content string
  43  	urls    []string
  44  }
  45  
  46  // mlsState is the MLS
  47  // Package globals are immutable outside initialization, so it lives in
  48  // one self-mutating type reached through a package-level pointer.
  49  type mlsState struct {
  50  	myPK          string
  51  	myKPP         *mls.KeyPairPackage
  52  	peerKPs       map[string]*mls.KeyPackage
  53  	mlsGroups     map[string]*marmot.GroupState // nostrGroupIDHex -> GroupState
  54  	initStarted   bool
  55  	initDone      bool
  56  	relayURLs     []string
  57  	unreadCount   int32
  58  
  59  	pendingIncoming []string // events queued before initDone
  60  
  61  	cryptoCBs    map[int32]func(string, string)
  62  	nextCryptoID int32
  63  	storeCBs     map[int32]func(string, string) // reqID -> fn(data, err)
  64  	nextStoreID  int32
  65  
  66  	// peer -> queued (content, urls) waiting for KP fetch
  67  	pendingKPFetch   map[string][]pendingSend
  68  	kpFetchTimers    map[string]int32
  69  
  70  	// groupIDHex -> queued event JSONs for messages that arrived before Welcome
  71  	pendingGroupMsgs map[string][]string
  72  
  73  	// peer -> recent DM records (JSON strings), ring buffer per peer
  74  	dmHistory map[string][]string
  75  }
  76  
  77  var mlsSt *mlsState
  78  
  79  func initState() {
  80  	if mlsSt != nil {
  81  		return
  82  	}
  83  	// mlsSt and everything it holds live as long as this worker: build them
  84  	// in the root arena, not in this call's frame arena which dies on
  85  	// return.
  86  	runtime.SovereignSetArena(runtime.RootArena())
  87  	mlsSt = &mlsState{}
  88  	mlsSt.peerKPs = map[string]*mls.KeyPackage{}
  89  	mlsSt.mlsGroups = map[string]*marmot.GroupState{}
  90  	mlsSt.cryptoCBs = map[int32]func(string, string){}
  91  	mlsSt.storeCBs = map[int32]func(string, string){}
  92  	mlsSt.pendingKPFetch = map[string][]pendingSend{}
  93  	mlsSt.kpFetchTimers = map[string]int32{}
  94  	mlsSt.pendingGroupMsgs = map[string][]string{}
  95  	mlsSt.dmHistory = map[string][]string{}
  96  	runtime.SovereignRestoreArena(runtime.RootArena())
  97  }
  98  
  99  func main() {
 100  	initState()
 101  	mlsw.WorkerOnMessage(handleMessage)
 102  }
 103  
 104  func handleMessage(msg string) {
 105  	w := mw.New(msg)
 106  	cmd := w.Str()
 107  	println("[mls-worker] handleMessage cmd=" | cmd)
 108  	switch cmd {
 109  	case "M_SET_PUBKEY":
 110  		mlsSt.myPK = w.Str()
 111  		println("[mls-worker] SET_PUBKEY pk=" | mlsSt.myPK[:16] | "...")
 112  	case "M_INIT", "MLS_INIT":
 113  		urlsJSON := w.Raw()
 114  		mlsSt.relayURLs = parseStrArr(urlsJSON)
 115  		println("[mls-worker] INIT urls=" | urlsJSON)
 116  		handleInit(mlsSt.relayURLs)
 117  	case "M_SEND_DM", "MLS_SEND_DM":
 118  		peer := w.Str()
 119  		content := w.Str()
 120  		urlsJSON := w.Raw()
 121  		urls := parseStrArr(urlsJSON)
 122  		if len(urls) == 0 {
 123  			urls = mlsSt.relayURLs
 124  		}
 125  		println("[mls-worker] SEND_DM peer=" | peer[:16] | "... content_len=" | itoi(len(content)) | " urls=" | itoi(len(urls)))
 126  		handleSendDM(peer, content, urls)
 127  	case "M_INCOMING":
 128  		evJSON := w.Raw()
 129  		if !mlsSt.initDone {
 130  			println("[mls-worker] INCOMING queued (mlsSt.initDone=false), pending=" | itoi(len(mlsSt.pendingIncoming)+1))
 131  			mlsSt.pendingIncoming = push(mlsSt.pendingIncoming, evJSON)
 132  			return
 133  		}
 134  		println("[mls-worker] INCOMING processing")
 135  		processIncoming(evJSON)
 136  	case "M_CRYPTO_RESULT":
 137  		reqID := int32(w.Num())
 138  		result := mw.Unescape(w.Str())
 139  		errMsg := mw.Unescape(w.Str())
 140  		println("[mls-worker] CRYPTO_RESULT reqID=" | itoi(reqID) | " err=" | errMsg)
 141  		if fn, ok2 := mlsSt.cryptoCBs[reqID]; ok2 {
 142  			delete(mlsSt.cryptoCBs, reqID)
 143  			fn(result, errMsg)
 144  		}
 145  	case "M_STORE_RESULT":
 146  		_ = w.Str() // tag (unused - keyed by reqID)
 147  		reqID := int32(w.Num())
 148  		data := w.Str()
 149  		println("[mls-worker] STORE_RESULT reqID=" | itoi(reqID) | " data_len=" | itoi(len(data)))
 150  		if fn, ok2 := mlsSt.storeCBs[reqID]; ok2 {
 151  			delete(mlsSt.storeCBs, reqID)
 152  			fn(data, "")
 153  		} else {
 154  			println("[mls-worker] STORE_RESULT: no callback for reqID=" | itoi(reqID))
 155  		}
 156  	case "M_FETCH_KP_RESULT":
 157  		peer := w.Str()
 158  		evJSON := w.Raw()
 159  		println("[mls-worker] FETCH_KP_RESULT peer=" | peer[:16] | "... evJSON_len=" | itoi(len(evJSON)))
 160  		handleFetchKPResult(peer, evJSON)
 161  	case "MLS_DM_LIST":
 162  		println("[mls-worker] DM_LIST requested")
 163  		handleDMList()
 164  	case "MLS_DM_HISTORY":
 165  		peer := w.Str()
 166  		println("[mls-worker] DM_HISTORY peer=" | peer[:16] | "...")
 167  		handleDMHistory(peer)
 168  	default:
 169  		println("[mls-worker] UNHANDLED cmd=" | cmd)
 170  	}
 171  }
 172  
 173  // --- Init ---
 174  
 175  func handleInit(urls []string) {
 176  	if mlsSt.initStarted {
 177  		println("[mls-worker] handleInit: already started, skipping")
 178  		return
 179  	}
 180  	if mlsSt.myPK == "" {
 181  		println("[mls-worker] handleInit: no pubkey, aborting")
 182  		return
 183  	}
 184  	mlsSt.initStarted = true
 185  	println("[mls-worker] handleInit: loading KPP from store")
 186  	storeReq("S_MLS_LOAD_KPP", "", func(kppBase64, _ string) {
 187  		if kppBase64 != "" {
 188  			println("[mls-worker] handleInit: got stored KPP, len=" | itoi(len(kppBase64)))
 189  			raw := helpers.Base64Decode(kppBase64)
 190  			if raw != nil {
 191  				if kpp, err := mls.UnmarshalKeyPairPackage(raw); err == nil {
 192  					mlsSt.myKPP = kpp
 193  					println("[mls-worker] handleInit: KPP loaded from store")
 194  				} else {
 195  					println("[mls-worker] handleInit: KPP unmarshal err=" | err.Error())
 196  				}
 197  			}
 198  		}
 199  		if mlsSt.myKPP == nil {
 200  			println("[mls-worker] handleInit: generating new KPP")
 201  			pubBytes := helpers.HexDecode(mlsSt.myPK)
 202  			if pubBytes == nil {
 203  				println("[mls-worker] handleInit: invalid pubkey hex")
 204  				return
 205  			}
 206  			now := mlsw.WorkerNowSeconds()
 207  			kpp, err := marmot.GenerateKeyPackage(pubBytes, now)
 208  			if err != nil {
 209  				println("[mls-worker] handleInit: generate KPP err=" | err.Error())
 210  				return
 211  			}
 212  			mlsSt.myKPP = kpp
 213  			println("[mls-worker] handleInit: KPP generated, saving")
 214  			raw, err := kpp.Marshal()
 215  			if err == nil {
 216  				storeFireForget("S_MLS_SAVE_KPP", jstr(helpers.Base64Encode(raw)))
 217  			}
 218  		}
 219  		println("[mls-worker] handleInit: publishing key package")
 220  		publishKeyPackage(urls, func() {
 221  			println("[mls-worker] handleInit: KP published, loading groups")
 222  			loadGroups(func() {
 223  				mlsSt.initDone = true
 224  				println("[mls-worker] handleInit: INIT DONE, groups=" | itoi(len(mlsSt.mlsGroups)) | " pending=" | itoi(len(mlsSt.pendingIncoming)))
 225  				pending := mlsSt.pendingIncoming
 226  				mlsSt.pendingIncoming = nil
 227  				for _, ev := range pending {
 228  					processIncoming(ev)
 229  				}
 230  				groupIDsJSON := buildGroupIDsJSON()
 231  				urlsJSON := buildURLsJSON(urls)
 232  				println("[mls-worker] handleInit: subscribing groups=" | groupIDsJSON)
 233  				mlsw.WorkerPost(`["M_SUBSCRIBE",` | urlsJSON | `,` | groupIDsJSON | `]`)
 234  				mlsw.WorkerPost(`["MLS_UNREAD_COUNT",0]`)
 235  			})
 236  		})
 237  	})
 238  }
 239  
 240  func publishKeyPackage(urls []string, done func()) {
 241  	now := mlsw.WorkerNowSeconds()
 242  	ev, err := marmot.BuildKeyPackageEvent(mlsSt.myKPP, mlsSt.myPK, now, urls)
 243  	if err != nil {
 244  		println("[mls-worker] publishKP: build err=" | err.Error())
 245  		done()
 246  		return
 247  	}
 248  	evJSON := ev.ToJSON()
 249  	println("[mls-worker] publishKP: unsigned kind=" | itoi(int32(ev.Kind)) | " evJSON(80)=" | evJSON[:80])
 250  	requestCrypto("signEvent", "", evJSON, func(signed, errMsg string) {
 251  		if errMsg != "" {
 252  			println("[mls-worker] publishKP: sign err=" | errMsg)
 253  		} else if signed != "" {
 254  			println("[mls-worker] publishKP: signed(80)=" | signed[:80])
 255  			mlsw.WorkerPost(`["M_PUBLISH",` | signed | `,` | buildURLsJSON(urls) | `]`)
 256  		} else {
 257  			println("[mls-worker] publishKP: sign returned empty")
 258  		}
 259  		done()
 260  	})
 261  }
 262  
 263  func loadGroups(done func()) {
 264  	storeReq("S_MLS_LIST_GROUPS", "", func(groupsJSON, _ string) {
 265  		ids := parseStrArr(groupsJSON)
 266  		if len(ids) == 0 {
 267  			done()
 268  			return
 269  		}
 270  		remaining := len(ids)
 271  		for _, id := range ids {
 272  			gid := id
 273  			storeReq("S_MLS_LOAD_GROUP", jstr(gid), func(stateBase64, _ string) {
 274  				if stateBase64 != "" {
 275  					raw := helpers.Base64Decode(stateBase64)
 276  					if raw != nil {
 277  						if gs, err := marmot.UnmarshalGroupState(raw); err == nil {
 278  							mlsSt.mlsGroups[gid] = gs
 279  						}
 280  					}
 281  				}
 282  				remaining--
 283  				if remaining == 0 {
 284  					done()
 285  				}
 286  			})
 287  		}
 288  	})
 289  }
 290  
 291  // --- Incoming events ---
 292  
 293  func processIncoming(evJSON string) {
 294  	ev := nostr.ParseEvent(evJSON)
 295  	if ev == nil {
 296  		println("[mls-worker] processIncoming: parse failed")
 297  		return
 298  	}
 299  	println("[mls-worker] processIncoming: kind=" | itoi(int32(ev.Kind)) | " from=" | ev.PubKey[:16] | "... id=" | ev.ID[:16] | "...")
 300  	switch ev.Kind {
 301  	case marmot.KindKeyPackage:
 302  		if kp, err := marmot.EventToKeyPackage(ev); err == nil {
 303  			cs := kp.CipherSuiteValue()
 304  			if cs != 3 {
 305  				println("[mls-worker] processIncoming: skip KP cs=" | itoi(int32(cs)) | " from " | ev.PubKey[:16] | "...")
 306  				break
 307  			}
 308  			if len(mlsSt.peerKPs) > 200 {
 309  				for k := range mlsSt.peerKPs {
 310  					delete(mlsSt.peerKPs, k)
 311  					break
 312  				}
 313  			}
 314  			mlsSt.peerKPs[ev.PubKey] = kp
 315  			println("[mls-worker] processIncoming: cached KP for " | ev.PubKey[:16] | "...")
 316  		} else {
 317  			println("[mls-worker] processIncoming: KP parse err=" | err.Error())
 318  		}
 319  	case marmot.KindGiftWrap:
 320  		println("[mls-worker] processIncoming: gift wrap, decrypting")
 321  		requestCrypto("nip44.decrypt", mlsSt.myPK, ev.Content, func(plaintext, errMsg string) {
 322  			if errMsg != "" {
 323  				println("[mls-worker] processIncoming: gift wrap decrypt err=" | errMsg)
 324  				return
 325  			}
 326  			if plaintext == "" {
 327  				println("[mls-worker] processIncoming: gift wrap decrypt empty")
 328  				return
 329  			}
 330  			inner := nostr.ParseEvent(plaintext)
 331  			if inner == nil {
 332  				println("[mls-worker] processIncoming: inner event parse failed")
 333  				return
 334  			}
 335  			println("[mls-worker] processIncoming: inner kind=" | itoi(int32(inner.Kind)))
 336  			switch inner.Kind {
 337  			case marmot.KindWelcome:
 338  				processWelcome(inner, ev.PubKey)
 339  			case marmot.KindGroupMessage:
 340  				processGroupMessage(inner)
 341  			default:
 342  				println("[mls-worker] processIncoming: unexpected inner kind=" | itoi(int32(inner.Kind)))
 343  			}
 344  		})
 345  	case marmot.KindGroupMessage:
 346  		processGroupMessage(ev)
 347  	default:
 348  		println("[mls-worker] processIncoming: unhandled kind=" | itoi(int32(ev.Kind)))
 349  	}
 350  }
 351  
 352  func processWelcome(rumorEv *nostr.Event, senderPubHex string) {
 353  	println("[mls-worker] processWelcome: from=" | senderPubHex[:16] | "...")
 354  	if mlsSt.myKPP == nil {
 355  		println("[mls-worker] processWelcome: no KPP, aborting")
 356  		return
 357  	}
 358  	welcome, err := marmot.RumorToWelcome(rumorEv)
 359  	if err != nil {
 360  		println("[mls-worker] processWelcome: parse err=" | err.Error())
 361  		return
 362  	}
 363  	peerPub := helpers.HexDecode(senderPubHex)
 364  	if peerPub == nil {
 365  		println("[mls-worker] processWelcome: bad sender hex")
 366  		return
 367  	}
 368  	now := mlsw.WorkerNowSeconds()
 369  	gs, err := marmot.JoinDMGroup(welcome, mlsSt.myKPP, peerPub, now)
 370  	if err != nil {
 371  		println("[mls-worker] processWelcome: join err=" | err.Error())
 372  		return
 373  	}
 374  	groupIDHex := helpers.HexEncode(gs.NostrGroupID)
 375  	println("[mls-worker] processWelcome: joined group=" | groupIDHex[:16] | "...")
 376  	mlsSt.mlsGroups[groupIDHex] = gs
 377  	saveGroup(groupIDHex, gs)
 378  	mlsw.WorkerPost(`["M_UPDATE_GROUPS",` | buildGroupIDsJSON() | `]`)
 379  
 380  	if queued, ok2 := mlsSt.pendingGroupMsgs[groupIDHex]; ok2 {
 381  		println("[mls-worker] processWelcome: draining " | itoi(len(queued)) | " pending messages")
 382  		delete(mlsSt.pendingGroupMsgs, groupIDHex)
 383  		for _, evJSON := range queued {
 384  			processIncoming(evJSON)
 385  		}
 386  	}
 387  }
 388  
 389  func processGroupMessage(ev *nostr.Event) {
 390  	hTag := nostr.TagsGetFirst(ev.Tags, "h")
 391  	if hTag == nil {
 392  		println("[mls-worker] processGroupMessage: no 'h' tag")
 393  		return
 394  	}
 395  	groupIDHex := string(nostr.TagValue(hTag))
 396  	println("[mls-worker] processGroupMessage: group=" | groupIDHex[:16] | "... from=" | ev.PubKey[:16] | "...")
 397  	gs, ok := mlsSt.mlsGroups[groupIDHex]
 398  	if !ok {
 399  		q := mlsSt.pendingGroupMsgs[groupIDHex]
 400  		if len(q) >= 64 {
 401  			println("[mls-worker] processGroupMessage: unknown group, pending queue full, dropping")
 402  			return
 403  		}
 404  		mlsSt.pendingGroupMsgs[groupIDHex] = push(q, ev.ToJSON())
 405  		println("[mls-worker] processGroupMessage: unknown group, queued (pending=" | itoi(len(mlsSt.pendingGroupMsgs[groupIDHex])) | ")")
 406  		return
 407  	}
 408  	secret, err := marmot.DeriveExporterSecret(gs.Group)
 409  	if err != nil {
 410  		println("[mls-worker] processGroupMessage: derive secret err=" | err.Error())
 411  		return
 412  	}
 413  	_, mlsCipher, err := marmot.EventToMessage(ev, secret)
 414  	if err != nil {
 415  		println("[mls-worker] processGroupMessage: EventToMessage err=" | err.Error())
 416  		return
 417  	}
 418  	plaintext, _, err := gs.Group.UnmarshalAndProcessMessage(mlsCipher)
 419  	if err != nil {
 420  		println("[mls-worker] processGroupMessage: process err=" | err.Error())
 421  		return
 422  	}
 423  	saveGroup(groupIDHex, gs)
 424  
 425  	peer := helpers.HexEncode(gs.PeerPub)
 426  	println("[mls-worker] processGroupMessage: decrypted msg from " | peer[:16] | "... len=" | itoi(len(plaintext)))
 427  	dmJSON := buildDMRecord(peer, ev.PubKey, plaintext, ev.CreatedAt, "mls", ev.ID)
 428  	storeDM(peer, dmJSON)
 429  	mlsw.WorkerPost(`["MLS_DM_RECEIVED",` | dmJSON | `]`)
 430  	mlsSt.unreadCount++
 431  	mlsw.WorkerPost(`["MLS_UNREAD_COUNT",` | itoi(mlsSt.unreadCount) | `]`)
 432  }
 433  
 434  // --- Send DM ---
 435  
 436  func handleSendDM(peer, content string, urls []string) {
 437  	groupIDHex := dmGroupIDHex(peer)
 438  	println("[mls-worker] handleSendDM: peer=" | peer[:16] | "... groupID=" | groupIDHex[:16] | "...")
 439  	if gs, ok2 := mlsSt.mlsGroups[groupIDHex]; ok2 {
 440  		println("[mls-worker] handleSendDM: existing group found, sending")
 441  		sendToExistingGroup(peer, content, urls, gs, groupIDHex)
 442  		return
 443  	}
 444  	if kp, ok2 := mlsSt.peerKPs[peer]; ok2 {
 445  		println("[mls-worker] handleSendDM: have peer KP, creating group")
 446  		createGroupAndSend(peer, content, urls, kp)
 447  		return
 448  	}
 449  	println("[mls-worker] handleSendDM: no KP, fetching from relay")
 450  	mlsSt.pendingKPFetch[peer] = push(mlsSt.pendingKPFetch[peer], pendingSend{content: content, urls: urls})
 451  	if _, fetching := mlsSt.kpFetchTimers[peer]; !fetching {
 452  		startKPFetch(peer, urls)
 453  	}
 454  }
 455  
 456  func startKPFetch(peer string, urls []string) {
 457  	mlsw.WorkerPost(`["M_FETCH_KP",` | jstr(peer) | `,` | buildURLsJSON(urls) | `]`)
 458  	p := peer
 459  	handle := mlsw.WorkerSetTimeout(15000, func() {
 460  		delete(mlsSt.kpFetchTimers, p)
 461  		if pending, ok2 := mlsSt.pendingKPFetch[p]; ok2 {
 462  			delete(mlsSt.pendingKPFetch, p)
 463  			for range pending {
 464  				mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(p) | `,0,"peer does not support MLS messaging"]`)
 465  			}
 466  		}
 467  	})
 468  	mlsSt.kpFetchTimers[peer] = handle
 469  }
 470  
 471  func handleFetchKPResult(peer, evJSON string) {
 472  	if h, ok2 := mlsSt.kpFetchTimers[peer]; ok2 {
 473  		mlsw.WorkerClearTimeout(h)
 474  		delete(mlsSt.kpFetchTimers, peer)
 475  	}
 476  	if evJSON != "" && evJSON != "null" {
 477  		ev := nostr.ParseEvent(evJSON)
 478  		if ev != nil {
 479  			if fkp, err := marmot.EventToKeyPackage(ev); err == nil {
 480  				cs := fkp.CipherSuiteValue()
 481  				if cs != 3 {
 482  					println("[mls-worker] handleFetchKPResult: skip KP cs=" | itoi(int32(cs)) | " for " | peer[:16] | "...")
 483  				} else {
 484  					mlsSt.peerKPs[peer] = fkp
 485  					println("[mls-worker] handleFetchKPResult: got KP for " | peer[:16] | "...")
 486  				}
 487  			} else {
 488  				println("[mls-worker] handleFetchKPResult: parse KP err=" | err.Error())
 489  			}
 490  		} else {
 491  			println("[mls-worker] handleFetchKPResult: parse event failed")
 492  		}
 493  	} else {
 494  		println("[mls-worker] handleFetchKPResult: no event returned for " | peer[:16] | "...")
 495  	}
 496  	pending, ok := mlsSt.pendingKPFetch[peer]
 497  	if !ok {
 498  		println("[mls-worker] handleFetchKPResult: no pending sends")
 499  		return
 500  	}
 501  	delete(mlsSt.pendingKPFetch, peer)
 502  	kp, hasKP := mlsSt.peerKPs[peer]
 503  	println("[mls-worker] handleFetchKPResult: hasKP=" | bstr(hasKP) | " pending=" | itoi(len(pending)))
 504  	for _, ps := range pending {
 505  		if !hasKP {
 506  			mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"peer does not support MLS messaging"]`)
 507  			continue
 508  		}
 509  		createGroupAndSend(peer, ps.content, ps.urls, kp)
 510  	}
 511  }
 512  
 513  func bstr(b bool) (s string) {
 514  	if b {
 515  		return "true"
 516  	}
 517  	return "false"
 518  }
 519  
 520  func createGroupAndSend(peer, content string, urls []string, peerKP *mls.KeyPackage) {
 521  	if mlsSt.myKPP == nil {
 522  		println("[mls-worker] createGroupAndSend: no KPP")
 523  		mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"not initialized"]`)
 524  		return
 525  	}
 526  	selfPub := helpers.HexDecode(mlsSt.myPK)
 527  	peerPub := helpers.HexDecode(peer)
 528  	if selfPub == nil || peerPub == nil {
 529  		println("[mls-worker] createGroupAndSend: bad pubkey hex")
 530  		mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"invalid pubkey"]`)
 531  		return
 532  	}
 533  	now := mlsw.WorkerNowSeconds()
 534  	peerCS := peerKP.CipherSuiteValue()
 535  	println("[mls-worker] createGroupAndSend: selfCS=" | itoi(int32(mlsSt.myKPP.Public.CipherSuiteValue())) | " peerCS=" | itoi(int32(peerCS)))
 536  	if peerCS != 3 {
 537  		println("[mls-worker] createGroupAndSend: unsupported peer cipher suite " | itoi(int32(peerCS)))
 538  		mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"unsupported peer cipher suite"]`)
 539  		return
 540  	}
 541  	gs, welcome, err := marmot.CreateDMGroup(mlsSt.myKPP, peerKP, selfPub, peerPub, "", urls)
 542  	if err != nil {
 543  		println("[mls-worker] createGroupAndSend: create err=" | err.Error())
 544  		mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
 545  		return
 546  	}
 547  	groupIDHex := helpers.HexEncode(gs.NostrGroupID)
 548  	println("[mls-worker] createGroupAndSend: group=" | groupIDHex[:16] | "... sending welcome")
 549  	mlsSt.mlsGroups[groupIDHex] = gs
 550  
 551  	rumorEv := marmot.WelcomeToRumor(welcome, selfPub, now, "", urls)
 552  	rumorJSON := rumorEv.ToJSON()
 553  	requestCrypto("nip44.encrypt", peer, rumorJSON, func(sealed, errMsg string) {
 554  		if errMsg != "" {
 555  			println("[mls-worker] createGroupAndSend: gift-wrap encrypt err=" | errMsg)
 556  			mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"gift-wrap encrypt failed"]`)
 557  			return
 558  		}
 559  		println("[mls-worker] createGroupAndSend: gift-wrap encrypted, signing")
 560  		wrapEv := &nostr.Event{
 561  			PubKey:    mlsSt.myPK,
 562  			CreatedAt: mlsw.WorkerNowSeconds(),
 563  			Kind:      marmot.KindGiftWrap,
 564  			Tags:      [][]string{[]string{"p", peer}},
 565  			Content:   sealed,
 566  		}
 567  		wrapJSON := wrapEv.ToJSON()
 568  		requestCrypto("signEvent", "", wrapJSON, func(signedWrap, errMsg string) {
 569  			if errMsg != "" {
 570  				println("[mls-worker] createGroupAndSend: sign wrap err=" | errMsg)
 571  			} else if signedWrap != "" {
 572  				println("[mls-worker] createGroupAndSend: publishing welcome (kind 1059)")
 573  				mlsw.WorkerPost(`["M_PUBLISH",` | signedWrap | `,` | buildURLsJSON(urls) | `]`)
 574  			}
 575  			p := peer
 576  			sendToExistingGroup(p, content, urls, gs, groupIDHex)
 577  		})
 578  	})
 579  
 580  	saveGroup(groupIDHex, gs)
 581  	mlsw.WorkerPost(`["M_UPDATE_GROUPS",` | buildGroupIDsJSON() | `]`)
 582  }
 583  
 584  func sendToExistingGroup(peer, content string, urls []string, gs *marmot.GroupState, groupIDHex string) {
 585  	now := mlsw.WorkerNowSeconds()
 586  	println("[mls-worker] sendToExistingGroup: creating app message")
 587  	appMsg, err := gs.Group.CreateApplicationMessage([]byte(content))
 588  	if err != nil {
 589  		println("[mls-worker] sendToExistingGroup: createAppMsg err=" | err.Error())
 590  		mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
 591  		return
 592  	}
 593  	secret, err := marmot.DeriveExporterSecret(gs.Group)
 594  	if err != nil {
 595  		println("[mls-worker] sendToExistingGroup: deriveSecret err=" | err.Error())
 596  		mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
 597  		return
 598  	}
 599  	ev445, err := marmot.MessageToEvent(gs.NostrGroupID, appMsg, secret, now)
 600  	if err != nil {
 601  		println("[mls-worker] sendToExistingGroup: MessageToEvent err=" | err.Error())
 602  		mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
 603  		return
 604  	}
 605  	println("[mls-worker] sendToExistingGroup: publishing kind 445 id=" | ev445.ID[:16] | "...")
 606  	mlsw.WorkerPost(`["M_PUBLISH",` | ev445.ToJSON() | `,` | buildURLsJSON(urls) | `]`)
 607  	saveGroup(groupIDHex, gs)
 608  
 609  	dmJSON := buildDMRecord(peer, mlsSt.myPK, content, now, "mls", ev445.ID)
 610  	storeDM(peer, dmJSON)
 611  	mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,1,""]`)
 612  	mlsw.WorkerPost(`["MLS_DM_RECEIVED",` | dmJSON | `]`)
 613  }
 614  
 615  // --- Store helpers ---
 616  
 617  func saveGroup(groupIDHex string, gs *marmot.GroupState) {
 618  	raw, err := marmot.MarshalGroupState(gs)
 619  	if err != nil {
 620  		return
 621  	}
 622  	b64 := helpers.Base64Encode(raw)
 623  	storeFireForget("S_MLS_SAVE_GROUP", jstr(groupIDHex) | `,` | jstr(b64))
 624  }
 625  
 626  func storeReq(op, extraArgs string, fn func(string, string)) {
 627  	mlsSt.nextStoreID++
 628  	id := mlsSt.nextStoreID
 629  	mlsSt.storeCBs[id] = fn
 630  	msg := `["M_STORE_REQ",` | jstr(op) | `,` | itoi(id)
 631  	if extraArgs != "" {
 632  		msg = msg | `,` | extraArgs
 633  	}
 634  	mlsw.WorkerPost(msg | `]`)
 635  }
 636  
 637  func storeFireForget(op, args string) {
 638  	mlsSt.nextStoreID++
 639  	id := mlsSt.nextStoreID
 640  	msg := `["M_STORE_REQ",` | jstr(op) | `,` | itoi(id)
 641  	if args != "" {
 642  		msg = msg | `,` | args
 643  	}
 644  	mlsw.WorkerPost(msg | `]`)
 645  }
 646  
 647  func requestCrypto(method, peer, data string, fn func(string, string)) {
 648  	mlsSt.nextCryptoID++
 649  	id := mlsSt.nextCryptoID
 650  	mlsSt.cryptoCBs[id] = fn
 651  	mlsw.WorkerPost(`["M_CRYPTO_REQ",` | itoi(id) | `,` | jstr(method) | `,` | jstr(peer) | `,` | jstr(data) | `]`)
 652  	cbID := id
 653  	mlsw.WorkerSetTimeout(15000, func() {
 654  		if _, ok2 := mlsSt.cryptoCBs[cbID]; ok2 {
 655  			delete(mlsSt.cryptoCBs, cbID)
 656  			fn("", "timeout")
 657  		}
 658  	})
 659  }
 660  
 661  // --- DM list / history ---
 662  
 663  func handleDMList() {
 664  	// Build conversation list from in-memory group states.
 665  	list := "["
 666  	first := true
 667  	for _, gs := range mlsSt.mlsGroups {
 668  		if !first {
 669  			list = list | ","
 670  		}
 671  		first = false
 672  		peer := helpers.HexEncode(gs.PeerPub)
 673  		list = list | `{"peer":` | jstr(peer) | `}`
 674  	}
 675  	list = list | "]"
 676  	mlsw.WorkerPost(`["MLS_DM_LIST",` | list | `]`)
 677  }
 678  
 679  func handleDMHistory(peer string) {
 680  	msgs := mlsSt.dmHistory[peer]
 681  	if len(msgs) == 0 {
 682  		mlsw.WorkerPost(`["MLS_DM_HISTORY",` | jstr(peer) | `,[]` | `]`)
 683  		return
 684  	}
 685  	// renderer expects newest-first (reverses to oldest-first for display)
 686  	arr := "["
 687  	for i := len(msgs) - 1; i >= 0; i-- {
 688  		if i < len(msgs)-1 {
 689  			arr = arr | ","
 690  		}
 691  		arr = arr | msgs[i]
 692  	}
 693  	arr = arr | "]"
 694  	mlsw.WorkerPost(`["MLS_DM_HISTORY",` | jstr(peer) | `,` | arr | `]`)
 695  }
 696  
 697  func storeDM(peer, dmJSON string) {
 698  	h := mlsSt.dmHistory[peer]
 699  	if len(h) >= 256 {
 700  		h = h[1:]
 701  	}
 702  	mlsSt.dmHistory[peer] = push(h, dmJSON)
 703  }
 704  
 705  // --- Helpers ---
 706  
 707  func dmGroupIDHex(peer string) (s string) {
 708  	selfPub := helpers.HexDecode(mlsSt.myPK)
 709  	peerPub := helpers.HexDecode(peer)
 710  	if selfPub == nil || peerPub == nil {
 711  		return ""
 712  	}
 713  	id := marmot.DMGroupID(selfPub, peerPub)
 714  	return helpers.HexEncode(id)
 715  }
 716  
 717  func buildGroupIDsJSON() (s string) {
 718  	s := "["
 719  	first := true
 720  	for id := range mlsSt.mlsGroups {
 721  		if !first {
 722  			s = s | ","
 723  		}
 724  		first = false
 725  		s = s | jstr(id)
 726  	}
 727  	return s | "]"
 728  }
 729  
 730  func buildURLsJSON(urls []string) (s string) {
 731  	s := "["
 732  	for i, u := range urls {
 733  		if i > 0 {
 734  			s = s | ","
 735  		}
 736  		s = s | jstr(u)
 737  	}
 738  	return s | "]"
 739  }
 740  
 741  func buildDMRecord(peer, from, content string, createdAt int64, protocol, eventID string) (s string) {
 742  	return `{"id":` | jstr(eventID) |
 743  		`,"peer":` | jstr(peer) |
 744  		`,"from":` | jstr(from) |
 745  		`,"content":` | jstr(content) |
 746  		`,"created_at":` | itoa(createdAt) |
 747  		`,"protocol":` | jstr(protocol) |
 748  		`,"eventId":` | jstr(eventID) |
 749  		`}`
 750  }
 751  
 752  func parseStrArr(json string) (ss []string) {
 753  	var out []string
 754  	i := 0
 755  	for i < len(json) && json[i] != '[' {
 756  		i++
 757  	}
 758  	if i >= len(json) {
 759  		return nil
 760  	}
 761  	i++
 762  	for {
 763  		for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') {
 764  			i++
 765  		}
 766  		if i >= len(json) || json[i] == ']' {
 767  			break
 768  		}
 769  		if json[i] != '"' {
 770  			break
 771  		}
 772  		i++
 773  		start := i
 774  		for i < len(json) && json[i] != '"' {
 775  			if json[i] == '\\' {
 776  				i++
 777  			}
 778  			i++
 779  		}
 780  		if i >= len(json) {
 781  			break
 782  		}
 783  		out = push(out, json[start:i])
 784  		i++
 785  	}
 786  	return out
 787  }
 788  
 789  func itoa(n int64) (s string) { return helpers.Itoa(n) }
 790  func itoi(n int32) (s string) { return helpers.Itoa(int64(n)) }
 791  func jstr(s string) (sv string) { return helpers.JsonString(s) }
 792  
 793