updated plans

This commit is contained in:
Evgeny @ SimpleX Chat
2026-05-30 22:07:52 +00:00
parent 8db9c38c37
commit 4a786e80f7
2 changed files with 566 additions and 3 deletions
+23 -3
View File
@@ -1,12 +1,32 @@
# SimpleX Web Widget: Master Plan
Revision 1, 2026-03-15
Revision 2, 2026-05-11
**Status**: Planning complete. Spike next.
**Status**: Spike 1 complete. Implementation planning.
**Related documents**:
- [Product Plan](./2026-03-15-simplex-web-widget-product.md) -- Users, UX, scope
- [Spike Plan](./2026-03-15-simplex-web-widget/2026-03-15-simplex-web-widget-spike.md) -- LGET proof of concept
- [Implementation Plan v2](./2026-03-15-simplex-web-widget/2026-05-11-implementation-v2.md) -- Three-phase plan based on spike findings
- [Spike 1 Plan](../../simplexmq-2/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md) -- Transport layer proof (complete)
- [Spec](../../simplexmq-2/spec/) -- Agent architecture, connections, message envelopes
## What changed since revision 1
**Spike 1 completed** (2026-03-15 to 2026-04-20): proved transport layer end-to-end. 24 tests against Haskell. WebSocket + SMP handshake + block encryption + server identity verification + short link fetch/decrypt/parse. Code lives in `simplexmq-2/smp-web/`, not throwaway.
**Key decisions made during spike**:
- SMP protocol version bumped to v19 (`webClientSMPVersion`) for server identity challenge-response
- Server challenge sent via URL query parameter (browser WebSocket API doesn't allow custom headers)
- xftp-web reused extensively (encoding, secretbox, identity verification, keys)
- Bottom-up approach validated: each function tested byte-for-byte against Haskell via `callNode`
**Architecture revised**:
- **One event loop, one database** -- the Haskell two-database/two-loop separation was an evolutionary artifact, not a design decision. Browser widget fuses agent + chat into one runtime.
- **Two packages**: smp-web (simplexmq-2) for protocol + agent runtime, chat-web (simplex-chat-2) for chat message types + widget UI. Agent takes a single `onEvent` callback + database handle from chat-web.
- **Persistence from day one** (IndexedDB) -- required for correct integrity chains. Encrypt at enqueue, not at send. Delivery worker is a dumb pump.
- **Real agent, no throwaway intermediate** -- no "simple agent that works under ideal conditions." Phase 1 produces tested pure functions. Phase 2 builds the shippable agent directly.
See [Implementation Plan v2](./2026-03-15-simplex-web-widget/2026-05-11-implementation-v2.md) for the full three-phase plan.
## Overview
@@ -0,0 +1,543 @@
# SimpleX Web Widget: Implementation Plan v2
Revision 2, 2026-05-11
**Status**: Planning
**Parent**: [Product Plan](./2026-03-15-simplex-web-widget-product.md)
**Spike 1 (complete)**: [smp-web spike](../../simplexmq-2/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md)
**Spec**: [simplexmq spec](../../simplexmq-2/spec/)
## What we know
Spike 1 proved the transport layer: WebSocket to SMP server, handshake with block encryption, server identity verification (challenge-response, v19), short link fetch + decrypt + parse. 24 tests, all byte-for-byte against Haskell.
smp-web (`simplexmq-2/smp-web/`) is the foundation - not throwaway.
## What we're building
A browser-native encrypted chat widget. Visitor opens widget, types name + message, sends it. Owner sees it in SimpleX Chat. Owner responds. Visitor sees the response. Supports direct chats, groups, and business addresses.
## The story
What happens when a visitor clicks "Send":
1. Widget parses the site owner's address (contact, group, or business)
2. Generates X448 key pairs, performs X3DH key agreement
3. Creates a receive queue on an SMP server (preferring a different operator than the owner's server)
4. Encrypts connection request with double ratchet, wraps in agent envelope, encrypts with per-queue E2E
5. Sends to owner's queue via SMP
6. Owner's app receives, decrypts, shows "new contact request"
7. Owner (or bot) accepts - sends reply with their queue info
8. Widget receives reply, completes handshake (KEY + HELLO exchange)
9. Connection is duplex - messages flow both ways, encrypted with double ratchet
What happens when the connection drops:
- SMP client reconnects with exponential backoff
- Resubscribes all queues on that server
- Pending messages in IndexedDB are retried by delivery workers
- No gaps in the integrity chain because messages were encrypted and persisted at enqueue time
What happens on page reload:
- Agent loads connection state, ratchet state, pending messages from IndexedDB
- Reconnects, resubscribes, resumes delivery
- Visitor sees conversation history
## Two packages
**smp-web** (in `simplexmq-2`): protocol encoding, crypto, agent message envelopes, ratchet, agent runtime. Mirrors simplexmq's Haskell. Tested byte-for-byte via `callNode`. The agent is configurable via a single event callback and an externally-provided database handle.
**chat-web** (in `simplex-chat-2`): chat message types, address type handling (contact/group/business), widget UI. Provides the event callback and database to the agent.
```typescript
// smp-web exports
createAgent({
db, // IndexedDB handle, passed in
onEvent: (event: AgentEvent) => void, // single callback, sum type
servers: SMPServer[]
})
// AgentEvent - mirrors Haskell AEvt
type AgentEvent =
| {type: "message", connId, msgId, msgBody}
| {type: "connected", connId}
| {type: "confirmation", connId, confId, connInfo}
| {type: "sent", connId, msgId}
| {type: "delivered", connId, msgId}
| {type: "error", connId, error}
// ...
// chat-web wires it together
const db = openDatabase()
const agent = createAgent({
db,
onEvent: chatHandleEvent, // chat's handler
servers
})
```
One event loop. One database. Agent doesn't know about chat. Chat doesn't reimplement the agent. In tests, pass a test handler and verify events.
## Types that make invalid states unrepresentable
```typescript
// Connection states carry their own data - no defensive checks
type Connection =
| {state: "new", connId: string}
| {state: "sending", connId: string, sndQueue: SndQueue, rcvQueue: RcvQueue}
| {state: "duplex", connId: string, sndQueue: SndQueue, rcvQueue: RcvQueue, ratchetId: string}
// A DuplexConnection always has both queues and an initialized ratchet.
// You can't call sendMessage with a SendingConnection - the type prevents it.
// Pending message is always encrypted - delivery worker never touches the ratchet
interface PendingMessage {
msgId: string
connId: string
sndQueueId: string
encryptedBody: Uint8Array // encrypted at enqueue time
createdAt: number
}
```
## Interfaces
**Agent API** (what chat-web calls):
```typescript
interface Agent {
joinConnection(connReqUri: string, connInfo: Uint8Array): Promise<string> // returns connId
sendMessage(connId: string, body: Uint8Array): Promise<string> // returns msgId
ackMessage(connId: string, msgId: string): Promise<void>
deleteConnection(connId: string): Promise<void>
}
```
**Store interface** (what both layers share):
```typescript
interface Store {
// Connections
createConnection(conn: Connection): Promise<void>
getConnection(connId: string): Promise<Connection>
updateConnection(connId: string, update: Partial<Connection>): Promise<void>
// Queues
createQueue(queue: Queue): Promise<void>
getQueuesForServer(server: string): Promise<Queue[]>
// Ratchet (atomic read-update-write)
withRatchet<T>(connId: string, fn: (state: RatchetState) => {result: T, newState: RatchetState}): Promise<T>
// Messages
enqueuePendingMessage(msg: PendingMessage): Promise<void>
getPendingMessages(sndQueueId: string): Promise<PendingMessage[]>
deletePendingMessage(msgId: string): Promise<void>
storeMessage(msg: StoredMessage): Promise<void>
// Commands (for resume on reload)
enqueuePendingCommand(cmd: PendingCommand): Promise<void>
getPendingCommands(): Promise<PendingCommand[]>
deletePendingCommand(cmdId: string): Promise<void>
}
```
The `withRatchet` interface ensures atomicity: the ratchet state is read, the function advances it synchronously (no await inside `fn`), and the new state is written in the same IndexedDB transaction.
## Three phases
### Phase 1: Protocol spike
Extend smp-web with all protocol encodings and crypto needed for connection establishment and messaging. Pure functions, tested against Haskell. No runtime, no persistence, no async.
New dependencies: `@noble/curves` (X448 DH), `@noble/ciphers` (AES-256-GCM for ratchet headers).
#### 1.1 SMP commands (`protocol.ts`)
| Function | Haskell reference | Test |
|----------|-------------------|------|
| `encodeNEW(rcvAuthKey, rcvDhKey, auth_, subMode, queueReqData)` | `encodeProtocol v (NEW ...)` | TS encodes → HS decodes as NEW |
| `decodeIDS(d)``{rcvId, sndId, rcvPublicDhKey, queueMode, linkId}` | `smpEncode (IDS ...)` | HS encodes → TS decodes |
| `encodeKEY(senderKey)` | `encodeProtocol v (KEY k)` | TS encodes → HS decodes |
| `encodeSKEY(senderKey)` | `encodeProtocol v (SKEY k)` | TS encodes → HS decodes |
| `encodeSUB()` | `encodeProtocol v SUB` | TS encodes → HS decodes |
| `encodeACK(msgId)` | `encodeProtocol v (ACK msgId)` | TS encodes → HS decodes |
| `encodeSEND(flags, msgBody)` | `encodeProtocol v (SEND flags msg)` | TS encodes → HS decodes |
| `decodeMSG(d)``{msgId, msgBody}` | `smpEncode (MSG ...)` | HS encodes → TS decodes |
| Extend `decodeResponse` for IDS, MSG, END, DELD | | |
Notes: NEW encoding is version-dependent (v19). Keys are DER-encoded (Ed25519 for auth, X25519 for DH). `MsgFlags` is a single byte. `auth_` is `Maybe SndPublicAuthKey` for TOFU.
#### 1.2 Agent message envelopes (`agent/protocol.ts`)
Level 1 - `AgentMsgEnvelope` (single-char discriminant):
| Function | Format | Test |
|----------|--------|------|
| `encodeAgentConfirmation(agentVersion, e2eEncryption_, encConnInfo)` | `(version, 'C', e2eParams, Tail encConnInfo)` | TS encodes → HS decodes |
| `decodeAgentConfirmation(d)` | | HS encodes → TS decodes |
| `encodeAgentMsgEnvelope(agentVersion, encAgentMessage)` | `(version, 'M', Tail msg)` | bidirectional |
| `decodeAgentMsgEnvelope(d)` | | |
| `encodeAgentInvitation(agentVersion, connReq, connInfo)` | `(version, 'I', Large connReq, Tail connInfo)` | bidirectional |
| `decodeAgentInvitation(d)` | | |
Level 2 - `AgentMessage`:
| Function | Format | Test |
|----------|--------|------|
| `encodeAgentConnInfo(connInfo)` | `('I', Tail connInfo)` | bidirectional |
| `encodeAgentConnInfoReply(smpQueues, connInfo)` | `('D', smpQueues, Tail connInfo)` | bidirectional |
| `encodeAgentMessage(hdr, aMsg)` | `('M', hdr, aMsg)` | bidirectional |
| `decodeAgentMessage(d)` | discriminant dispatch | |
Level 3 - `AMessage` + `APrivHeader`:
| Function | Format | Test |
|----------|--------|------|
| `encodeAPrivHeader(sndMsgId, prevMsgHash)` | `(sndMsgId, prevMsgHash)` | bidirectional |
| `decodeAPrivHeader(d)` | | |
| `encodeHELLO()` | `"H"` | bidirectional |
| `encodeA_MSG(body)` | `("M", Tail body)` | bidirectional |
| `encodeA_RCVD(receipt)` | `("V", receipt)` | bidirectional |
| `decodeAMessage(d)` | dispatch on H/M/V/etc | |
E2E ratchet params:
| Function | Test |
|----------|------|
| `encodeSndE2ERatchetParams(vRange, x448key1, x448key2)` | bidirectional |
| `decodeSndE2ERatchetParams(d)` | |
| `encodeRcvE2ERatchetParams(vRange, x448key1, x448key2)` | bidirectional |
| `decodeRcvE2ERatchetParams(d)` | |
SMP queue info (for connInfo reply):
| Function | Test |
|----------|------|
| `encodeSMPQueueInfo(queue)` | bidirectional |
| `decodeSMPQueueInfo(d)` | |
#### 1.3 X3DH key agreement (`crypto/ratchet.ts`)
| Function | Haskell reference | Test |
|----------|-------------------|------|
| `generateX448KeyPair()` | via `@noble/curves/ed448` x448 | |
| `x448DH(publicKey, privateKey)` | `dh'` for X448 | Same keys → same shared secret |
| `encodePubKeyX448(rawKey)` → DER | `encodePubKey` for X448 | bidirectional |
| `decodePubKeyX448(der)` → raw | `x509ToPublic'` for X448 | bidirectional |
| `pqX3dhSnd(ourKeys, theirKeys)``{rootKey, headerKey, nextHeaderKey}` | `pqX3dhSnd` | Same inputs → identical output |
| `pqX3dhRcv(ourKeys, theirKeys)``{rootKey, headerKey, nextHeaderKey}` | `pqX3dhRcv` | Same inputs → identical output |
Notes: 4-DH with X448 (56-byte keys). HKDF-SHA512, info="SimpleXX3DH", output 96 bytes split 32+32+32. Roles reversed from Signal: joiner = Bob = initSndRatchet. PQ KEM deferred.
#### 1.4 Double ratchet (`crypto/ratchet.ts`)
| Function | Haskell reference | Test |
|----------|-------------------|------|
| `initSndRatchet(x3dhResult, ratchetKeyPair)``RatchetState` | `initSndRatchet` | Same X3DH output → same initial state |
| `initRcvRatchet(x3dhResult, ratchetKeyPair)``RatchetState` | `initRcvRatchet` | |
| `rootKdf(rootKey, dhSecret)``{rootKey, chainKey, nextHeaderKey}` | HKDF-SHA512 "SimpleXRootRatchet", 96 bytes | byte-for-byte |
| `chainKdf(chainKey)``{chainKey, messageKey, iv1, iv2}` | HKDF-SHA256 "SimpleXCK"/"SimpleXMK", 96 bytes | byte-for-byte |
| `rcEncryptHeader(state, headerPlain)``{encHeader, messageKey}` | AES-256-GCM, pad to 88 bytes | |
| `rcDecryptHeader(state, encHeader)``{headerPlain, decryptMode}` | Try HKr, NHKr, skipped keys | |
| `rcEncryptMsg(messageKey, iv, body, paddedLen)` → encrypted | AES-256-GCM | |
| `rcDecryptMsg(messageKey, iv, encrypted)` → body | AES-256-GCM | |
| `rcEncrypt(state, body)``{encMessage, newState}` | Full encrypt (header + body) | TS encrypt → HS decrypt |
| `rcDecrypt(state, encMessage)``{body, newState}` | Full decrypt | HS encrypt → TS decrypt |
Tests: init ratchet pair from X3DH. Encrypt TS → decrypt HS. Vice versa. Out-of-order delivery. Skipped keys (up to maxSkip=512). Chain key advancement matches after N messages.
#### 1.5 Per-queue E2E encryption (`crypto.ts`)
| Function | Haskell reference | Test |
|----------|-------------------|------|
| `encryptForQueue(queueDhKey, senderDhPrivKey, body)` | `encryptSndMsg` (X25519 DH → XSalsa20-Poly1305) | TS encrypt → HS decrypt |
| `decryptFromQueue(rcvDhPrivKey, senderDhPubKey, envelope)` | `decryptRcvMsg` | HS encrypt → TS decrypt |
May reuse xftp-web `cbEncrypt`/`cbDecrypt` if the format matches. Verify exact SMP per-queue envelope format.
#### 1.6 ConnectionRequestUri parsing (`agent/protocol.ts`)
| Function | Haskell reference | Test |
|----------|-------------------|------|
| `decodeConnectionRequestUri(d)` | `smpP @AConnectionRequestUri` | HS encodes → TS decodes |
| `decodeConnReqUriData(d)``{agentVRange, smpQueues, clientData}` | `smpP @ConnReqUriData` | |
| `decodeSMPQueueUri(d)``{clientVRange, server, senderId, dhPublicKey, queueMode}` | `smpP @SMPQueueUri` | |
| `encodeConnectionRequestUri(cr)` | `smpEncode` | for building invitation connReqs |
Both contact (`'C'`) and invitation (`'I'`) variants.
#### 1.7 Connection request assembly (`agent/protocol.ts`)
| Function | Test |
|----------|------|
| `buildConfirmation(agentVersion, e2eParams, connInfo, ratchetState)` → full AgentConfirmation bytes | HS agent decodes and accepts |
| `buildInvitation(agentVersion, connReq, connInfo)` → full AgentInvitation bytes | HS agent decodes |
| `encodeConnInfo(profile, replyQueues)` → connInfo bytes | |
| `wrapForQueue(queueDhKey, senderDhPrivKey, envelope)` → per-queue encrypted message | |
Integration point: chains X3DH + ratchet encrypt + envelope encode + per-queue E2E. Test against real Haskell agent accepting a connection.
#### 1.8 Chat message encoding (`chat/protocol.ts` in chat-web)
| Function | Direction | Test |
|----------|-----------|------|
| `encodeChatMessage(event, params, msgId?)` → JSON bytes | send | HS decodes |
| `decodeChatMessage(bytes)``{event, params}` | receive | HS encodes → TS decodes |
| `encodeXInfo(profile)` | send | |
| `encodeXMsgNew(content)` | send | |
| `decodeXMsgNew(params)` | receive | |
| `decodeXMsgUpdate(params)` | receive only | |
| `decodeXMsgDel(params)` | receive only | |
| `decodeXGrpLinkInv(params)` | receive | business address response |
| `decodeXGrpMemIntro(params)` | receive | group member intro |
| `decodeXGrpMemInv(params)` | receive | group member invitation |
| `decodeXGrpMemFwd(params)` | receive | group member forward |
| `decodeXGrpMemNew(params)` | receive | new member announcement |
| `decodeXGrpAcpt(params)` | receive | group accept |
| `decompressZstd(compressed)` | receive | for compressed messages |
Format: `AppMessageJson {v: ChatVersionRange, msgId?: SharedMsgId, event: string, params: object}`. Agent version: 7. E2E version: 3. Chat version range: current.
#### 1.9 Handwired end-to-end test
Chain all functions: parse address → generate keys → X3DH → build confirmation → encode envelope → per-queue E2E encrypt → send via WebSocket → receive reply → decrypt → HELLO exchange → send encrypted message → receive response.
Test is throwaway. Functions are real. Proves they compose correctly end-to-end against a Haskell agent.
### Phase 2: Shippable agent runtime
The real agent with persistence, delivery workers, reconnection, subscription maintenance. No simplified intermediate. Lives in smp-web.
**2.1 SMP client**
File: `client.ts`
WebSocket connection to one SMP server:
- Command/response correlation via corrId
- Bounded send/receive queues (ABQueue pattern from simplexmq-js)
- Keepalive (PING/PONG)
- Error classification (transient vs permanent)
Test: connect to test SMP server, send commands, verify responses.
**2.2 Connection pool + reconnection**
One SMP client per server. On disconnect:
- Exponential backoff reconnection (base 500ms, max 30s, jitter)
- Resubscribe all queues on that server after reconnect
- Pending deliveries resume automatically (workers read from store)
**2.3 Async command dispatch**
Network commands are never synchronous. Each command:
1. Persisted to IndexedDB (crash-safe)
2. Worker woken to execute
3. On page reload, `getPendingCommands()` resumes all
Commands: NEW, KEY, SKEY, SUB, ACK, SEND (via delivery worker).
**2.4 Delivery workers**
Per-send-queue workers:
- Read pending encrypted messages from IndexedDB
- Send via SMP client
- On OK: delete from store, emit SENT via `onEvent`
- On delivery receipt from peer: emit DELIVERED via `onEvent`
- On transient failure: retry with backoff
- On permanent failure: emit error via `onEvent`
Workers are independent - one server being down doesn't block others.
**2.5 Message receive path**
1. SMP client receives MSG
2. `withRatchet`: decrypt header + body atomically, update ratchet state
3. Verify integrity chain (sndMsgId + prevMsgHash)
4. Store decrypted message
5. Emit event via `onEvent` callback
6. Enqueue ACK as async command
**2.6 Connection state machine**
For joiner (widget is always the joiner):
```
NewConnection
→ join contact/group address
→ create receive queue (NEW)
→ send confirmation (SKEY + SEND)
SndConnection
→ receive reply (MSG with connInfo)
→ register sender key (KEY)
→ send HELLO
DuplexConnection
→ receive HELLO
→ emit CON (connected)
```
Group flow adds: receive `x.grp.mem.intro` → establish separate connections with each member (each goes through the same state machine). Business address: receive `x.grp.link.inv` with `BusinessChatInfo` instead of `x.info`.
**2.7 IndexedDB store**
One database. Implements the `Store` interface. Schema:
| Table | Contents |
|-------|----------|
| `connections` | state, queue refs, metadata, chat-level info |
| `queues` | server, IDs, keys, subscription status |
| `ratchets` | double ratchet state per connection |
| `pendingMessages` | encrypted messages awaiting delivery |
| `messages` | received/sent message history |
| `pendingCommands` | async commands for resume on reload |
`withRatchet` uses a single IndexedDB readwrite transaction to ensure atomicity.
**2.8 Server selection**
When creating the visitor's receive queue, prefer a different operator than the owner's server (mirrors Haskell `getNextServer` logic). For MVP: the widget is configured with a server list. The selection avoids using the same server as the contact address when possible.
**2.9 End-to-end integration test**
TypeScript agent ↔ Haskell agent:
- Connection establishment (contact address)
- Bidirectional message exchange
- Clean integrity chain
- Delivery receipts
- Reconnection + resubscription
- Page reload recovery (persistence)
### Phase 3: Chat protocol and widget
Chat-level semantics wired into the agent via `onEvent`. Lives in chat-web (simplex-chat-2).
**3.1 Chat event handler**
The `onEvent` callback that chat-web provides to the agent:
```typescript
function chatHandleEvent(event: AgentEvent): void {
switch (event.type) {
case "message":
const chatMsg = decodeChatMessage(event.msgBody)
// dispatch by chat message type
break
case "connected":
// update UI - connection established
break
case "confirmation":
// for groups: auto-accept member introductions
break
// ...
}
}
```
**3.2 Address type handling**
All three types use the same agent `joinConnection`. The differences are in how the response is handled:
- **Contact address**: receive `x.info` (profile) → 1:1 conversation
- **Group link**: receive group info + member introductions → establish per-member connections → group conversation
- **Business address**: receive `x.grp.link.inv` with `BusinessChatInfo` → group conversation that looks like 1:1 to visitor
For groups and business chats: each member introduction triggers a new `joinConnection` through the agent. The agent handles the connection lifecycle. Chat just dispatches.
**3.3 Group member introduction flow**
When visitor joins a group/business chat:
1. Host sends `x.grp.mem.new` to existing members
2. Each existing member sends `x.grp.mem.fwd` with connection request
3. Visitor receives `x.grp.mem.inv` for each member
4. Chat handler calls `agent.joinConnection` for each → separate duplex connection per member
5. Messages in the group are sent to each member's connection independently
This is why per-queue delivery workers matter - each member may be on a different server.
**3.4 Widget UI**
Minimal component library:
- Chat bubble (collapsed, unread indicator)
- Chat window (expanded, message list, input)
- Connection screen (name + incognito + first message)
- Message list with delivery status (single/double checkmark)
- System messages (member joined, connection status)
- Received replies displayed with quoted content
- Dark mode with site sync API
- Accent color customization
- Mobile: full screen when expanded
**3.5 Embedding**
```html
<script
src="https://simplex.chat/widget.js"
data-address="simplex:/contact#..."
data-accent="#0066cc"
integrity="sha384-..."
crossorigin="anonymous"
></script>
```
## Build approach
Bottom-up, function-by-function, each tested against Haskell.
Phase 1: pure functions. Each encoding, each crypto operation verified byte-for-byte. The foundation.
Phase 2: the real agent runtime. Tested against live Haskell SMP servers and agents. No throwaway intermediate.
Phase 3: chat semantics + UI. Protocol is proven. This is application logic.
## What's deferred
- **PQ KEM (SNTRUP761)**: X448-only for MVP. Additive - `PQSupport` monotonic.
- **Queue rotation**: additive (QADD/QKEY/QUSE/QTEST).
- **Ratchet synchronization**: MVP shows error, suggests reconnecting.
- **Private routing/proxy**: additive (PRXY/PFWD wraps existing send).
- **File transfer (XFTP)**: separate protocol.
- **Web Push notifications**: via notification router, post-MVP.
- **Migration to app**: QR code transfer, post-MVP.
- **Message forwarding, live messages**: post-MVP.
- **Message editing/deletion on send side**: receive-only for MVP.
## What's NOT deferred
- **Persistence (IndexedDB)**: required for correct integrity chains.
- **Encrypt at enqueue**: required - delivery worker never touches ratchet.
- **Delivery workers with retry**: required for reliability across server failures.
- **Subscription maintenance**: required for message delivery after reconnection.
- **Async command dispatch**: required - sync commands block the event loop.
- **Double ratchet with header encryption**: non-negotiable.
- **X3DH (X448)**: non-negotiable.
- **Agent message envelope format**: byte-compatible with Haskell.
- **Groups + business addresses**: required for MVP.
- **Delivery receipts**: required for checkmark UX.
- **Replies (receive side)**: required for MVP.
## Resolved questions
**Where does code live?** smp-web (simplexmq-2) for protocol + agent runtime. chat-web (simplex-chat-2) for chat message types + widget UI. Agent takes `onEvent` callback + `db` handle from chat-web. One event loop, one database, composed via dependency injection.
**Business address flow**: business address is a contact address where the owner's app creates a group internally. The widget receives `XGrpLinkInv` with `BusinessChatInfo` instead of `XInfo`. Protocol-level, it's the same join flow - the difference is in the chat layer response handling.
**Which server for visitor's queue**: prefer different operator than the owner's server. Widget configured with a server list. Mirrors Haskell `getNextServer` logic.
**Group join**: each member establishes a separate duplex connection with the new joiner. A 3-member group creates ~8 SMP queues for one new joiner (group connections only, no direct connections for widget MVP). Per-member connections go through independent state machines.
**ConnInfo format**: JSON `AppMessageJson {v, msgId, event: "x.info", params: {profile: ...}}`. Same format for contact and group addresses. Business address uses `event: "x.grp.link.inv"` in the response.
**Agent/E2E versions**: agent v7 (`currentSMPAgentVersion`), E2E v3 (`currentE2EEncryptVersion`). Widget advertises current versions.
## Open questions
### Bundle size
libsodium ~550KB unpacked. Need to measure total. May need lazy loading.
### Web Workers
Crypto in Web Worker avoids blocking main thread. Measure first.
### restoreShortLink
Widget needs preset server list for shortened links. How provided?
### CORS
Required for cross-origin embedding. Pattern exists in XFTP server. Not yet in SMP.