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