@solarpunkltd/swarm-chat-js 9.0.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,37 +1,59 @@
1
1
  # Swarm Chat JS
2
2
 
3
- A client library for chat over [Swarm](https://www.ethswarm.org/). A browser signs each message and sends it as one
4
- GSOC write to a shared inbox. A chat server verifies it and publishes it to the chat's feed, and every reader follows
5
- that feed. The browser talks only to a Bee node or gateway, never to a web server of its own.
6
-
7
- The library needs its server,
8
- [Solar-Punk-Ltd/swarm-chat-aggregator-js](https://github.com/Solar-Punk-Ltd/swarm-chat-aggregator-js), which listens on
9
- the inbox and writes the feed. Version 9 of the library reads and writes the v9 message format only, and a message of any
10
- earlier version fails its shape check.
3
+ `@solarpunkltd/swarm-chat-js` is a client library that adds a chat to any web page or Node program, with every message
4
+ stored on [Swarm](https://www.ethswarm.org/), the decentralised storage network. A page that uses it needs no web server
5
+ of its own. It talks only to a Bee node, which is a Swarm node, or to a gateway, which is a Bee node that serves the
6
+ public over HTTP.
7
+
8
+ The library is one half of a chat. The other half is its server,
9
+ [swarm-chat-aggregator-js](https://github.com/Solar-Punk-Ltd/swarm-chat-aggregator-js), which receives the messages,
10
+ checks them and publishes them for every reader. Any project can run the server and use the library to add chat on
11
+ Swarm. One project built on them is [stream-event-client](https://github.com/Solar-Punk-Ltd/stream-event-client), a
12
+ viewer for live streams with a chat beside each video.
13
+
14
+ ## Terms
15
+
16
+ - **Chunk.** The unit Swarm stores, at most 4,096 bytes, found by its address.
17
+ - **Single owner chunk.** A chunk whose address comes from an owner's key and an identifier, so only that key can write
18
+ it.
19
+ - **GSOC.** A single owner chunk whose key is shared on purpose, so anyone can write to it. A Bee node near its address
20
+ can subscribe to it and hears every write. The chat uses one GSOC address as a shared inbox.
21
+ - **Feed.** A sequence of single owner chunks written by one owner under one topic, at index 0, 1, 2 and on. Each chat is
22
+ one feed, and the server is its only writer.
23
+ - **Postage stamp.** Every write to Swarm is paid for with a stamp from a postage batch. A gateway can stamp writes
24
+ itself, or a sender can stamp with a batch of its own.
11
25
 
12
26
  ## How it works
13
27
 
14
- 1. **Sending.** The sender builds the message, signs it with the user's key, and writes it once to the inbox, a GSOC
15
- address every chat shares. The message stays pending until the chat feed shows it. If it does not, the identical
16
- bytes are written again every ten seconds, up to five times, and then it is failed with a manual retry.
28
+ 1. **Sending.** The library builds the message, signs it with the user's key, and writes it once to the inbox. The
29
+ message stays pending until the chat's feed shows it. If it does not show, the identical bytes are written again
30
+ every ten seconds, up to five times, and the message is then marked failed so the page can offer a retry.
17
31
  2. **Publishing.** The server checks each message's shape and signature and writes it to the chat's feed as the next
18
- entry, at index 0, 1, 2 and on. After a message is published it saves a history file of the chat, and every entry
19
- links to the newest file saved. Once a two-second time slot ends in which a message landed, and at least every 30
20
- seconds while the chat is active, it writes a slot note naming the newest entry. See
32
+ entry. After each publish it saves a history file of the chat, and every entry links to the newest history file.
33
+ The server also writes slot notes, small chunks that say which entries exist. See
21
34
  [Slot notes](#slot-notes-for-servers-and-tools).
22
- 3. **Opening.** A reader reads the notes of the slots just ended, newest first, back as far as one heartbeat. The
23
- newest note found names the newest entry, whose history file is shown, and the entries after it are read. Only
24
- when no note is found, which is a server that writes none, does it ask Bee for the feed's head instead.
25
- 4. **Following.** Once each time slot is over, plus a second, a reader asks for that slot's note once, and reads the
26
- feed entries up to the newest one a note named, never further. So it never asks Bee for an address before it is
27
- written. Bee answers such a request by skipping its peers for that address for a minute, which made new messages
28
- arrive about forty seconds late while readers polled the next slot. An entry a note named that does not load is
29
- read again later, so a message arrives late rather than never. A slot that fails its checks is skipped and never
30
- stops the chat. Gateway failures back off with jitter up to eight seconds. A reader that finds no note, which is a
31
- server that writes none or a chat it has not opened yet, polls the next slot, and is led by notes from the first
32
- one it finds. Any address that came back missing, a feed slot or a note, is asked for again no sooner than twenty
33
- seconds later, three times a minute at most, which keeps Bee from skipping its peers for it.
34
- 5. **Older messages.** Loading older messages is a click, which reads the history file before the oldest one shown.
35
+ 3. **Opening.** A reader reads the slot notes of the last few seconds, newest first, going back up to one heartbeat
36
+ (30 seconds by default). The newest note names the newest entry. The reader shows that entry's history file and
37
+ reads the entries after it. When it finds no note, it asks Bee for the feed's newest index instead.
38
+ 4. **Following.** Once each two-second time slot is over, plus one second, the reader asks for that slot's note once.
39
+ It reads feed entries only up to the newest one a note named, so it never asks Bee for an address that is not
40
+ written yet. This matters because Bee answers such a request by skipping its peers for that address for about a
41
+ minute, which would delay the message once it is written. An entry a note named that does not load is read again
42
+ later, so a message arrives late rather than never. A slot that fails its checks is skipped. Gateway failures back
43
+ off with jitter, up to eight seconds. A reader that finds no note polls the next feed index instead, and switches
44
+ to notes as soon as it finds one. An address that came back missing is asked for again no sooner than 20 seconds
45
+ later.
46
+ 5. **Older messages.** Loading older messages is a call the page makes, usually on a click. It reads the history file
47
+ before the oldest one shown.
48
+
49
+ ## What you need
50
+
51
+ - A running [swarm-chat-aggregator-js](https://github.com/Solar-Punk-Ltd/swarm-chat-aggregator-js). Its operator gives
52
+ you the inbox identifier and key, the feed owner address and the slot note timing.
53
+ - A Bee node or gateway to read through, and one to write through. Write through a different Bee node from the one the
54
+ server listens on. Bee hands an inbox write to the server only when the chunk arrives from another node, so a write
55
+ made on the listening node itself reaches the server only if it later syncs back.
56
+ - A way to pay for the inbox writes: a gateway that stamps them, or a postage batch of the sender's own.
35
57
 
36
58
  ## Installation
37
59
 
@@ -74,26 +96,26 @@ await chat.stop();
74
96
 
75
97
  ## Settings
76
98
 
77
- | Setting | Meaning |
78
- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
79
- | `user.privateKey` | Signs every message. A viewer that only reads may pass any key. |
80
- | `user.nickname` | The name on every message, 1 to 20 characters, not ending in `.eth` as a reader sees it. Nothing can be sent without one. |
81
- | `user.binding` | Optional. A signed-in viewer's Swarm ID chat binding, `{ msg, sig }`, for the chat address of `user.privateKey`. Every message then carries it. |
82
- | `user.walletLink` | Optional, only with `user.binding`. The viewer's Swarm ID wallet link, `{ msg, wsig, asig }`, for the binding's account. Every message then carries it. |
83
- | `infra.beeUrl` | The Bee node or gateway the chat is read through, and written through when `infra.writeUrl` is not set. |
84
- | `infra.writeUrl` | Optional. The Bee node or gateway the inbox writes go through, such as a write gateway that stamps them, when they go somewhere other than the reads. |
85
- | `infra.stamp` | The batch the inbox writes are stamped with. Left out for a gateway that stamps writes itself. |
86
- | `infra.via` | Required. Who pays for the inbox writes, `MessageVia.GATEWAY` or `MessageVia.OWN_STAMP`, as every message says in `via`. |
87
- | `infra.gsocTopic` | The inbox's identifier string, the same for every chat. |
88
- | `infra.gsocResourceId` | The mined key every sender signs inbox writes with. The server's operator mines it. |
89
- | `infra.chatTopic` | The chat's topic, which every message carries and the feed is named by. |
90
- | `infra.chatAddress` | The server's feed owner address. |
91
- | `infra.pollingInterval` | The shortest wait between two polls, and the first wait after the gateway failed, 1,000 ms by default. |
92
- | `infra.socReadTimeout` | One feed slot read, 5,000 ms by default. |
93
- | `infra.feedReadTimeout` | The head lookup and a history file download, 15,000 ms by default. A head lookup that runs out of time reads the chat from slot 0. |
94
- | `infra.gsocWriteTimeout` | One inbox write, 10,000 ms by default. |
95
- | `infra.noteSlotMs` | The server's `NOTE_SLOT_MS`, 2,000 ms by default. It is part of every note's address, so a different value finds no notes and polls instead. |
96
- | `infra.noteHeartbeatMs` | The server's `NOTE_HEARTBEAT_MS`, 30,000 ms by default, which sets how far back an opening looks for a note. |
99
+ | Setting | Meaning |
100
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
101
+ | `user.privateKey` | Signs every message. A viewer that only reads may pass any key. |
102
+ | `user.nickname` | The name on every message, 1 to 20 characters. It may not read as an ENS name ending in `.eth`. Nothing can be sent without one. |
103
+ | `user.binding` | Optional. A signed-in viewer's Swarm ID chat binding, `{ msg, sig }`, for the chat address of `user.privateKey`. Every message then carries it. |
104
+ | `user.walletLink` | Optional, only with `user.binding`. The viewer's Swarm ID wallet link, `{ msg, wsig, asig }`, for the binding's account. Every message then carries it. |
105
+ | `infra.beeUrl` | The Bee node or gateway the chat is read through, and written through when `infra.writeUrl` is not set. |
106
+ | `infra.writeUrl` | Optional. The Bee node or gateway the inbox writes go through, such as a write gateway that stamps them, when they go somewhere other than the reads. |
107
+ | `infra.stamp` | The postage batch id the inbox writes are stamped with. Left out for a gateway that stamps writes itself. |
108
+ | `infra.via` | Required. Who pays for the inbox writes, `MessageVia.GATEWAY` or `MessageVia.OWN_STAMP`. Every message records it in `via`. |
109
+ | `infra.gsocTopic` | The inbox's identifier string, the same for every chat on one server. |
110
+ | `infra.gsocResourceId` | The inbox key every sender signs inbox writes with. The server's operator mines it so the inbox sits near the server's listening node. |
111
+ | `infra.chatTopic` | The chat's topic. Every message carries it, and the chat's feed is named by it. |
112
+ | `infra.chatAddress` | The server's feed owner address. |
113
+ | `infra.pollingInterval` | The shortest wait between two polls, and the first wait after the gateway failed. 1,000 ms by default. |
114
+ | `infra.socReadTimeout` | The time limit for one feed entry read. 5,000 ms by default. |
115
+ | `infra.feedReadTimeout` | The time limit for the newest-index lookup and for a history file download. 15,000 ms by default. A lookup that runs out of time reads the chat from index 0. |
116
+ | `infra.gsocWriteTimeout` | The time limit for one inbox write. 10,000 ms by default. |
117
+ | `infra.noteSlotMs` | The server's `NOTE_SLOT_MS`, 2,000 ms by default. It is part of every note's address, so a different value finds no notes and the reader polls instead. |
118
+ | `infra.noteHeartbeatMs` | The server's `NOTE_HEARTBEAT_MS`, 30,000 ms by default. It sets how far back an opening reader looks for a note. |
97
119
 
98
120
  ## Methods
99
121
 
@@ -106,7 +128,7 @@ await chat.stop();
106
128
  | `retrySendMessage(message)` | Writes a failed or pending message again with its identical bytes. |
107
129
  | `fetchPreviousMessages()` | Shows the history file before the oldest one shown, and resolves to its messages. |
108
130
  | `hasPreviousMessages()` | Whether there is an older file to load. |
109
- | `orderMessages(messages)` | Published messages in chat order, then pending ones by time. |
131
+ | `orderMessages(messages)` | Published messages in chat order, then pending ones by the sender's clock. |
110
132
  | `getStatus()` | `live`, `reconnecting`, `stalled`, or `null` before the first read. |
111
133
  | `getAddress()` | The address of the key, which is every sent message's `address`. |
112
134
 
@@ -118,74 +140,109 @@ await chat.stop();
118
140
  | `LOADING_PREVIOUS_MESSAGES` | `boolean` | Around a load of older messages. |
119
141
  | `MESSAGE_RECEIVED` | `MessageData` | A message the feed or a history file shows. For a sender, this is what "sent" means. |
120
142
  | `MESSAGE_REQUEST_INITIATED` | `MessageData` | A message signed and about to be written, with `index` -1. |
121
- | `MESSAGE_REQUEST_UPLOADED` | `MessageData` | Its first write the node accepted. It is still pending. |
122
- | `MESSAGE_REQUEST_ERROR` | `MessageData` | Its resends ran out unseen. |
143
+ | `MESSAGE_REQUEST_UPLOADED` | `MessageData` | The first write of the message that the node accepted. It is still pending. |
144
+ | `MESSAGE_REQUEST_ERROR` | `MessageData` | Its resends ran out and the feed never showed it. |
123
145
  | `STATUS` | `FeedStatus` | `live`: the gateway answers. `reconnecting`: it does not. `stalled`: a message known to exist has not loaded for eight seconds. |
124
- | `MESSAGE_SKIPPED` | `SkippedMessage` | A feed slot or history row not shown, and why. |
146
+ | `MESSAGE_SKIPPED` | `SkippedMessage` | A feed entry or history row that is not shown, and why. |
125
147
  | `ERROR` | `unknown` | A failure the chat recovers from on its own. |
126
148
  | `CRITICAL_ERROR` | `unknown` | Opening failed three times in a row. It keeps trying. |
127
149
 
128
150
  **The order of a sent message's events.** `MESSAGE_REQUEST_INITIATED` comes first, then `MESSAGE_REQUEST_UPLOADED`
129
151
  once a write is accepted, then `MESSAGE_RECEIVED` when the feed shows it. The resends and the give-up wait while the
130
- status is not `live`, so a message is not failed only because this viewer could not see the feed. A message can still
131
- be failed and then shown, for example when the server published it after the last resend, so `MESSAGE_RECEIVED` may
132
- follow `MESSAGE_REQUEST_ERROR` for the same `id`, and a viewer settles on the received one.
152
+ status is not `live`, so a message is not marked failed only because this viewer cannot see the feed. A message can
153
+ still be marked failed and then shown, for example when the server published it after the last resend. So
154
+ `MESSAGE_RECEIVED` may follow `MESSAGE_REQUEST_ERROR` for the same `id`, and the page keeps the received one.
155
+
156
+ A `MessageData` has these fields:
133
157
 
134
- A `MessageData` has `id`, `type`, `message` (the text or emoji), `username`, `address`, `timestamp` (the server's
135
- receive time, or the sender's clock while pending), `targetMessageId` for a reply or reaction, `index` (the feed index,
136
- -1 while pending) and `signedMessage`, the message exactly as its sender signed it. Everything else the message says,
137
- its topic, its signature, the sender's clock, `via` and the binding, is read from `signedMessage`, and a page
138
- that shows who sent it hands `signedMessage` to `verifyChatMessage`. See [Verifying](#verifying).
158
+ - `id`, `type`, `message` (the text or emoji), `username` and `address`.
159
+ - `timestamp`: the server's receive time, or the sender's clock while pending.
160
+ - `targetMessageId`: the message a reply or reaction refers to.
161
+ - `index`: the feed index, -1 while pending.
162
+ - `signedMessage`: the message exactly as its sender signed it.
163
+
164
+ Everything else the message says, such as its topic, its signature, the sender's clock, `via` and the binding, is read
165
+ from `signedMessage`. A page that shows who sent a message hands `signedMessage` to `verifyChatMessage`. See
166
+ [Verifying](#verifying).
139
167
 
140
168
  ## The message, for servers and tools
141
169
 
142
- `@solarpunkltd/swarm-chat-js/message` is the one place the message is built, signed and checked, and the server imports
143
- it rather than keeping a copy. It loads none of the reader.
170
+ `@solarpunkltd/swarm-chat-js/message` is the one place the message is built, signed and checked. The server imports it
171
+ rather than keeping its own copy. This entry point loads none of the reader.
144
172
 
145
173
  ```ts
146
174
  import { parseChatMessage, MAX_MESSAGE_BYTES } from '@solarpunkltd/swarm-chat-js/message';
147
175
 
148
- const check = parseChatMessage(payload, { origin: 'https://example.com' });
176
+ const check = parseChatMessage(payload, { origins: ['https://example.com'] });
149
177
  if (check.ok) publish(check.message);
150
178
  else count(check.reason);
151
179
  ```
152
180
 
153
- A message is a JSON object of at most 3,072 bytes of UTF-8, its fields in this order:
154
-
155
- | Field | Meaning |
156
- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
157
- | `v` | `9` |
158
- | `topic` | The chat's topic, 1 to 128 characters |
159
- | `id` | A random id, 32 lowercase hex characters |
160
- | `type` | `text`, `thread` (a reply, `target` is the parent) or `reaction` (`target` is the message, `text` the emoji) |
161
- | `target` | The id it refers to, or `""` for a text |
162
- | `text` | 1 to 500 characters |
163
- | `name` | 1 to 20 characters, none a Unicode format or control character (`\p{Cf}`, `\p{Cc}`) but U+200C and U+200D, no blank letter, not ending in `.eth` as a reader sees it |
164
- | `addr` | The sender's chat address, 40 lowercase hex characters without `0x` |
165
- | `ts` | The sender's clock in milliseconds |
166
- | `via` | Who paid for the inbox write: `gateway` (the gateway's stamp) or `own-stamp` (the sender's own) |
167
- | `idb` | Only for a signed-in sender: its Swarm ID chat binding, `{ msg, sig }` |
168
- | `wl` | Only beside `idb`, for a sender who shows an ENS name: its Swarm ID wallet link, `{ msg, wsig, asig }` |
169
- | `sig` | 130 lowercase hex characters of r, s and v, with v 27 or 28 and s in the lower half |
170
-
171
- Characters are counted as Unicode code points, so an emoji counts once, and `countCharacters` counts the same way for
172
- a composer. The signed bytes are the UTF-8 of
173
- `JSON.stringify([9, topic, id, type, target, text, name, addr, ts, via, idb?.msg ?? '', idb?.sig ?? ''])`, with
174
- `wl.msg`, `wl.wsig` and `wl.asig` appended to the array when the message carries a wallet link, signed with bee-js's
175
- `PrivateKey.sign` and checked with `Signature.recoverPublicKey`, both over the raw bytes. So `via`, the binding
176
- and the wallet link cannot be changed, added or taken off without breaking the signature. Every signature has a second valid
177
- spelling, s replaced by n - s with v flipped, and bee-js reads any v but 27 as 28, so the check refuses all but the
178
- low s spelling with v 27 or 28, the one bee-js signs. One message has one valid signature.
181
+ A message is a JSON object of at most 3,072 bytes of UTF-8, with its fields in this order:
182
+
183
+ | Field | Meaning |
184
+ | -------- | ------------------------------------------------------------------------------------------------------------ |
185
+ | `v` | `9`, the message format version |
186
+ | `topic` | The chat's topic, 1 to 128 characters |
187
+ | `id` | A random id, 32 lowercase hex characters |
188
+ | `type` | `text`, `thread` (a reply, `target` is the parent) or `reaction` (`target` is the message, `text` the emoji) |
189
+ | `target` | The id it refers to, or `""` for a text |
190
+ | `text` | 1 to 500 characters |
191
+ | `name` | The display name, 1 to 20 characters, with the limits in [Display names](#display-names) |
192
+ | `addr` | The sender's chat address, 40 lowercase hex characters without `0x` |
193
+ | `ts` | The sender's clock in milliseconds |
194
+ | `via` | Who paid for the inbox write: `gateway` (the gateway's stamp) or `own-stamp` (the sender's own) |
195
+ | `idb` | Only for a signed-in sender: its Swarm ID chat binding, `{ msg, sig }` |
196
+ | `wl` | Only beside `idb`, for a sender who shows an ENS name: its Swarm ID wallet link, `{ msg, wsig, asig }` |
197
+ | `sig` | 130 lowercase hex characters of r, s and v, with v 27 or 28 and s in the lower half |
198
+
199
+ Characters are counted as Unicode code points, so an emoji counts once. `countCharacters` counts the same way, for a
200
+ text box that shows a count.
201
+
202
+ The signed bytes are the UTF-8 of
203
+ `JSON.stringify([9, topic, id, type, target, text, name, addr, ts, via, idb?.msg ?? '', idb?.sig ?? ''])`. When the
204
+ message carries a wallet link, `wl.msg`, `wl.wsig` and `wl.asig` are appended to that array. The library signs with
205
+ bee-js's `PrivateKey.sign` and checks with `Signature.recoverPublicKey`, both over the raw bytes. So `via`, the binding
206
+ and the wallet link cannot be changed, added or removed without breaking the signature.
207
+
208
+ Every signature has a second valid spelling, with s replaced by n - s and v flipped, and bee-js reads any v other than
209
+ 27 as 28. The check therefore accepts only the low s spelling with v 27 or 28, which is the one bee-js produces. One
210
+ message has exactly one valid signature.
211
+
212
+ ### Display names
213
+
214
+ A name may not contain a Unicode format or control character (`\p{Cf}`, `\p{Cc}`), such as the right-to-left override
215
+ U+202E or a zero width space, because those can make one name look like another. The zero width non-joiner U+200C and
216
+ the zero width joiner U+200D are allowed, because Persian and Indic names and joined emoji need them. Variation
217
+ selectors such as U+FE0F are allowed too. Letters and marks that show as nothing are refused: the braille blank U+2800,
218
+ the Hangul fillers U+115F, U+1160, U+3164 and U+FFA0, the combining grapheme joiner U+034F and the Khmer inherent vowels
219
+ U+17B4 and U+17B5.
220
+
221
+ A name that reads as ending in `.eth` is refused, because a page may show a verified ENS name in the same place and a
222
+ chosen name could pass for one. The check builds a skeleton of the name, the name as a reader sees it:
223
+
224
+ - NFKD normalised, so fullwidth and other compatibility forms fold and accented letters lose their accents
225
+ - every character Unicode's confusables data maps to the full stop read as `.`, and the ideographic full stop U+3002
226
+ too, since ENS reads it as one
227
+ - Unicode default ignorable code points, the braille blank, and nonspacing and enclosing marks removed
228
+ - letters the confusables data maps to `e`, `t` or `h` read as those letters, such as Cyrillic `е`, `т`, `н` and Greek
229
+ `Ε`, `Τ`, `Η`
230
+ - lower-cased, trimmed, and stripped of trailing spaces and dots
231
+
232
+ The name is refused when the skeleton ends in `.eth`. So `alice.eth`, `Alice.ETH`, `alice.eth.`, `alice.еth`
233
+ (Cyrillic `е`), `alice٠eth` (Arabic-Indic zero) and `ALICE.ETH` are refused, while `alice eth`,
234
+ `alice-eth`, `Kenneth`, `Seth`, `Ethan` and `J.Ethan` pass. `displayNameProblem(name)` runs the same check for a
235
+ sign-up form.
179
236
 
180
237
  ### The Swarm ID chat binding
181
238
 
182
- A Swarm ID account gives each site its own chat key, and nothing in that key says which account it came from. The fork
183
- derives it from the site's app secret, itself HMAC-SHA256 of the site's origin under the account key: the chat key is
184
- HMAC-SHA256 of `swarm-id chat key v1` under the app secret, asked again under `swarm-id chat key v1/1`, `/2` and on
185
- while the result is not a valid secp256k1 private key. This library never derives it. It only checks the address a
186
- binding names. The binding is the account's statement, signed with the account's own key, that one chat address on one
187
- site is its own. The Swarm ID fork signs it, and this library reads it exactly as the fork's `verifyChatBinding` does,
188
- without importing the fork. `msg` is four lines joined by `\n`:
239
+ [Swarm ID](https://github.com/Solar-Punk-Ltd/solarpunk-id) is a separate identity project. A Swarm ID account gives each
240
+ site its own chat key, and nothing in that key says which account it came from. A chat binding is the account's
241
+ statement, signed with the account's own key, that one chat address on one site belongs to it. This library does not
242
+ derive chat keys and does not import Swarm ID. It only checks the bindings, and reads them exactly as Swarm ID's
243
+ `verifyChatBinding` does.
244
+
245
+ `msg` is four lines joined by `\n`:
189
246
 
190
247
  ```text
191
248
  Swarm ID chat binding v1
@@ -194,20 +251,22 @@ Chat address: 0x<addr>
194
251
  Date: 2026-10-14T12:00:00.000Z
195
252
  ```
196
253
 
197
- The site is a bare https origin as a browser reports it (plain http only for `localhost`, `127.0.0.1` and `[::1]`, and
198
- only with `allowLocalHttp`), the chat address is `0x` and 40 lowercase hex characters, and the date is ISO 8601 UTC with
199
- milliseconds. `sig` is EIP-191 `personal_sign` over `msg`, written as the fork writes it: `0x` and 130 lowercase hex
200
- characters of r, s and v, v 27 or 28 and s in the lower half. Recovering it gives the account's address.
254
+ - The site is a bare https origin as a browser reports it. Plain http is accepted only for `localhost`, `127.0.0.1` and
255
+ `[::1]`, and only with the `allowLocalHttp` option.
256
+ - The chat address is `0x` and 40 lowercase hex characters.
257
+ - The date is ISO 8601 UTC with milliseconds.
258
+ - `sig` is an EIP-191 `personal_sign` signature over `msg`: `0x` and 130 lowercase hex characters of r, s and v, with v
259
+ 27 or 28 and s in the lower half. Recovering it gives the account's address.
201
260
 
202
261
  ### The Swarm ID wallet link
203
262
 
204
- Swarm ID never tells a site the wallet an account is secured with. A person who wants readers to see their ENS name
205
- turns on "Show my ENS name on sites" there, and Swarm ID then gives the site a wallet link: the statement, signed by the
206
- wallet and countersigned by the account's own key, that the two belong to the same person. Its canonical description
207
- is `docs/Wallet-Link.md` in the Swarm ID fork, solarpunk-id, and this library reads it exactly as the fork's
208
- `verifyWalletLink` does, without importing the fork. Swarm ID hands it over as
209
- `{ message, walletSignature, accountSignature }`, and a message carries it as `{ msg, wsig, asig }`. `msg` is five lines
210
- joined by `\n`:
263
+ Swarm ID never tells a site which wallet secures an account. A person who wants readers to see their ENS name turns on
264
+ "Show my ENS name on sites" in Swarm ID. Swarm ID then gives the site a wallet link: a statement, signed by the wallet
265
+ and countersigned by the account's own key, that the two belong to the same person. Swarm ID hands it over as
266
+ `{ message, walletSignature, accountSignature }`, and a message carries it as `{ msg, wsig, asig }`. This library reads
267
+ it exactly as Swarm ID's `verifyWalletLink` does.
268
+
269
+ `msg` is five lines joined by `\n`:
211
270
 
212
271
  ```text
213
272
  Swarm ID wallet link v1
@@ -217,87 +276,92 @@ Wallet: 0x<the wallet>
217
276
  Date: 2026-10-15T12:00:00.000Z
218
277
  ```
219
278
 
220
- The second line is read exactly. It is there because a wallet shows the person only the text it signs, so the sentence
221
- tells them what the signature does. Both addresses are `0x` and 40 lowercase hex characters, and the date is ISO 8601
222
- UTC with milliseconds. `wsig` and
223
- `asig` are EIP-191 `personal_sign` over `msg` by the wallet and by the account, each `0x` and 130 lowercase hex
224
- characters of r, s and v, v 27 or 28 and s in the lower half.
279
+ - The second line must match exactly. A wallet shows the person only the text it signs, so this sentence tells them
280
+ what the signature does.
281
+ - Both addresses are `0x` and 40 lowercase hex characters, and the date is ISO 8601 UTC with milliseconds.
282
+ - `wsig` and `asig` are EIP-191 `personal_sign` signatures over `msg` by the wallet and by the account, each `0x` and
283
+ 130 lowercase hex characters, with v 27 or 28 and s in the lower half.
225
284
 
226
- A message carries a link only beside its binding, and the link counts toward the 3,072 byte cap. The largest message
285
+ A message carries a link only beside its binding, and the link counts toward the 3,072 byte limit. The largest message
227
286
  with a binding and a link is 2,376 bytes.
228
287
 
229
288
  ### Verifying
230
289
 
231
- `verifyChatMessage(value, { origin, allowLocalHttp? })` checks a message's shape and its signature, and then, when it
232
- carries a binding, that the binding's text is those four lines, its chat address is the message's `addr`, its signature
233
- recovers an account, and its site is `origin`. It returns `{ ok: true, message, identity }`, `identity` being
234
- `{ account, site, date }` for a signed-in sender and `null` for a guest. A message whose binding fails is refused with
235
- the reason `binding` and is never shown as a guest.
236
-
237
- When the message also carries a wallet link, the link's text must be those five lines, its account the binding's
238
- account, its wallet signature must recover its wallet and its account signature its account. `identity` then also has
239
- `wallet`, the checked wallet address. A link that fails refuses the message with the reason `binding` too, its detail
240
- starting `wallet link:`. The check looks nothing up. A page that shows an ENS name takes `identity.wallet` to its own
241
- lookup, such as the fork's `resolveVerifiedEnsName`, which requires the wallet's reverse record and the name's forward
242
- record to agree, over an Ethereum endpoint the page names.
243
-
244
- `origin` is required, the bare origin of the site whose chat this is, such as `https://example.com`. A binding signed
245
- for one site therefore never passes in another site's chat because a caller left the origin out. A JavaScript caller
246
- that passes none anyway gets every bound message refused as `binding`. The server passes its configured site origin,
247
- and a page passes its own.
248
-
249
- **What `identity.account` proves, and what it does not.** It proves only that whoever controls that Swarm ID account
250
- bound this chat key for this site, on the date the binding names. It is not a verified person, and it is not an
251
- account anyone has allowed. One person can hold many accounts, an account can be made in a minute, and a lost or shared
252
- account key binds just as well. A page that wants more, such as a badge for known people or a list of accounts allowed
253
- to post, keeps that list itself and compares `identity.account` with it.
254
-
255
- The server calls it, through `parseChatMessage`, on every payload. The reader in this library still checks no
256
- signature, since that cost 6.x about 36 ms of main thread per message, so a page that shows identities runs
257
- `verifyChatMessage` in a Web Worker on each event's `MessageData.signedMessage`, which is the feed
258
- entry's `msg` or, while pending, the message as it was signed. It never throws, and it touches no DOM and no Node API.
290
+ `verifyChatMessage(value, { origins, allowLocalHttp? })` checks a message's shape and signature. When the message carries
291
+ a binding, it also checks that the binding's text is the four lines above, that its chat address is the message's
292
+ `addr`, that its signature recovers an account, and that its site is one of `origins`. It returns
293
+ `{ ok: true, message, identity }`. `identity` is `{ account, site, date }` for a signed-in sender and `null` for a
294
+ guest. A message whose binding fails is refused with the reason `binding`, and is never shown as a guest.
295
+
296
+ When the message also carries a wallet link, the link's text must be the five lines above, its account must be the
297
+ binding's account, its wallet signature must recover its wallet, and its account signature must recover its account.
298
+ `identity` then also has `wallet`, the checked wallet address. A link that fails refuses the message with the reason
299
+ `binding`, with a detail starting `wallet link:`. The check looks nothing up. A page that shows an ENS name passes
300
+ `identity.wallet` to its own ENS lookup, such as Swarm ID's `resolveVerifiedEnsName`, which requires the wallet's reverse
301
+ record and the name's forward record to agree.
302
+
303
+ `origins` is required. It lists the bare origins of the sites whose chat this is, such as `https://example.com`, so a
304
+ binding signed for one site never passes in a chat that site is not part of. A chat served from several pages, such as
305
+ a domain and a copy of the same page on Swarm under an ENS name, lists every one, and each page then shows the
306
+ messages sent from the others as signed in. An empty list, or none from a JavaScript caller, refuses every bound
307
+ message as `binding`. The server passes its configured sites, and a page passes its own origin and the others it was
308
+ told of.
309
+
310
+ **What `identity.account` proves.** It proves only that whoever controls that Swarm ID account bound this chat key for
311
+ this site, on the date the binding names. It is not a verified person and not an approved account. One person can hold
312
+ many accounts, an account takes a minute to make, and a lost or shared account key binds just as well. A page that
313
+ wants more, such as a badge for known people or a list of accounts allowed to post, keeps that list itself and compares
314
+ `identity.account` with it.
315
+
316
+ The server runs this check, through `parseChatMessage`, on every payload. The reader in this library checks no
317
+ signatures, because the server already did and a check costs main-thread time on every message. A page that shows
318
+ identities runs `verifyChatMessage` in a Web Worker on each event's `MessageData.signedMessage`. That is the feed
319
+ entry's `msg`, or the message as it was signed while it is pending. `verifyChatMessage` never throws, and it uses no DOM
320
+ and no Node API.
259
321
 
260
322
  ### The own feed
261
323
 
262
324
  A sender may also keep a copy of each message it sends in a feed of its own, so anyone can compare that feed with the
263
- chat. The copies live in a feed owned by the sender's chat address `addr`, under the topic `ownFeedTopic(chatTopic)`,
264
- which is `swarm-chat-own-feed-v1:` and the chat's topic, turned into a feed topic with `Topic.fromString` as the chat's
265
- own feed is. A copy is the signed payload itself. The message says nothing about a copy, so whether one exists is read
266
- from that feed.
325
+ chat. The copies live in a feed owned by the sender's chat address `addr`, under the topic `ownFeedTopic(chatTopic)`.
326
+ That topic is `swarm-chat-own-feed-v1:` followed by the chat's topic, turned into a feed topic with `Topic.fromString`
327
+ as the chat's own feed is. A copy is the signed payload itself. The message says nothing about a copy, so whether one
328
+ exists is read from that feed.
329
+
330
+ The library does not write the copy. A page that keeps copies passes its own inbox write function, a `GsocWrite`, as
331
+ `new SwarmChat(settings, { write })`. That function receives the signed payload, the same bytes on every resend, and
332
+ writes the copy beside the inbox write.
267
333
 
268
- The library does not write the copy. A page that keeps copies passes its own inbox write, a `GsocWrite`, as
269
- `new SwarmChat(settings, { write })`. It is handed the signed payload, the same bytes on every resend, and writes the
270
- copy beside the inbox write.
334
+ ### Exports
271
335
 
272
336
  | Export | Use |
273
337
  | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
274
- | `parseChatMessage(bytes, { origin })` | The server's check of a raw payload: byte cap, UTF-8, JSON, then `verifyChatMessage`. Never throws. |
275
- | `verifyChatMessage(value, { origin })` | Shape, signature and binding of a parsed object, with the sender's identity. Never throws. |
338
+ | `parseChatMessage(bytes, { origins })` | The server's check of a raw payload: byte limit, UTF-8, JSON, then `verifyChatMessage`. Never throws. |
339
+ | `verifyChatMessage(value, { origins })` | Shape, signature and binding of a parsed object, with the sender's identity. Never throws. |
276
340
  | `displayNameProblem(name)` | `null` for a name a message may carry, else `'length'` (not 1 to 20 characters), `'invisible'` (a format or control character) or `'ens-like'` (reads as ending in `.eth`). |
277
341
  | `createChatMessage(key, draft)` | Builds and signs, throwing a `ChatMessageError` for anything the check would refuse. `via` is required. |
278
342
  | `MessageType`, `MessageVia` | The values of `type` and `via`. |
279
- | `checkChatBinding(binding, addr, options?)`, `chatBindingSchema` | The binding check on its own and its shape. Its `origin` is optional, for a sender checking its own binding. |
343
+ | `checkChatBinding(binding, addr, options?)`, `chatBindingSchema` | The binding check on its own, and its shape. Its `origins` is optional, for a sender checking its own binding, and left out accepts any site. |
280
344
  | `checkWalletLink(link, account)`, `walletLinkSchema` | The wallet link check on its own, for the binding's account, and its shape. |
281
345
  | `ownFeedTopic(chatTopic)`, `OWN_FEED_TOPIC_PREFIX` | The topic of a sender's own feed of copies. |
282
- | `chatMessageSchema`, `hasValidSignature`, `signedBytes`, `encodeChatMessage` | The parts. |
346
+ | `chatMessageSchema`, `hasValidSignature`, `signedBytes`, `encodeChatMessage` | The parts of the message check. |
283
347
  | `feedEntrySchema`, `parseFeedEntry` | The feed entry, `{v, seq, at, msg, history}`. |
284
348
  | `historyFileSchema`, `parseHistoryFile` | The history file, `{v, topic, fromSeq, toSeq, messages, prev}`. |
285
349
 
286
- The feed entry and the history file carry `v` 9 like the message. A feed entry, at most 4,096 bytes, holds a message at
287
- the byte cap inside its largest wrapper.
350
+ The feed entry and the history file carry `v` 9, like the message. A feed entry is at most 4,096 bytes, enough for a
351
+ message at the byte limit inside the largest entry around it.
288
352
 
289
353
  ## Slot notes, for servers and tools
290
354
 
291
- A note tells readers which feed entries exist, so they never ask for one that does not. Time slot `s` covers
292
- `[s * slotMs, (s + 1) * slotMs)` in Unix milliseconds, `slotMs` being 2,000 by default. The note of slot `s` is a
293
- single owner chunk signed by the chat feed's key, at the identifier keccak256 of the UTF-8 text
294
- `<chat topic>/note/<slotMs>/<s>`, and is read through `GET /chunks/<address>`. Its payload is
295
- `{"v":1,"newest":<the highest feed index written>,"writtenAt":<the server's clock in ms>}`, `newest` -1 for a chat with
296
- nothing written yet, and a payload that fails this strict shape counts as no note.
355
+ A slot note tells readers which feed entries exist, so they never ask for one that does not. Time slot `s` covers
356
+ `[s * slotMs, (s + 1) * slotMs)` in Unix milliseconds, with `slotMs` 2,000 by default. The note of slot `s` is a single
357
+ owner chunk signed by the chat feed's key. Its identifier is keccak256 of the UTF-8 text
358
+ `<chat topic>/note/<slotMs>/<s>`, and it is read through `GET /chunks/<address>`. Its payload is
359
+ `{"v":1,"newest":<the highest feed index written>,"writtenAt":<the server's clock in ms>}`, with `newest` -1 for a chat
360
+ with nothing written yet. A payload that does not match this exact shape counts as no note.
297
361
 
298
- The server writes a slot's note once the slot is over, when an entry landed since its last note that was written, or
299
- when a heartbeat has passed since that note. An entry's own write has finished before any note names it. A note write
300
- that fails is not retried at its address: the next slot's note carries the same news.
362
+ The server writes a slot's note once the slot is over, when an entry landed since its last note, or when a heartbeat
363
+ has passed since that note. An entry's own write has finished before any note names it. A note write that fails is not
364
+ retried at its address, because the next slot's note carries the same information.
301
365
 
302
366
  | Export | Use |
303
367
  | ---------------------------------------------------------------- | ------------------------------------------------------ |
@@ -307,105 +371,27 @@ that fails is not retried at its address: the next slot's note carries the same
307
371
  | `DEFAULT_NOTE_SLOT_MS`, `DEFAULT_NOTE_HEARTBEAT_MS` | The defaults both sides share. |
308
372
 
309
373
  **The reader's clock.** A reader schedules note reads by its own clock. Every note's `writtenAt` and every entry's `at`
310
- was stamped before the reader received it, so the reader runs at most `received - stamped` ahead of the server. While
311
- notes are found it trusts its own clock, moving reads earlier only when that bound proves the clock runs behind. When
312
- no note is found for longer than a heartbeat, it waits out the whole bound from then on, which is never early. A
313
- clock that runs behind sees every stamp as old as it expects, so its messages arrive as late as it runs behind.
314
-
315
- ## Changes in 9.0
316
-
317
- - A message may carry the sender's Swarm ID wallet link, `wl`, beside its binding, signed with the rest. A verified
318
- link puts the wallet on `identity.wallet`, and a link that fails refuses the message as `binding`.
319
- - The message is version 9, its `v` and the first element of its signed bytes both `9`, and so is every feed entry and
320
- history file. A message, entry or history file of version 8 fails its shape check, whether or not it carries `own`,
321
- so a reader and a server on 9.0 never read one that 8.x wrote, and the break shows as a version rather than as a
322
- missing field.
323
- - The message no longer carries `own`. It is gone from the shape, the signed bytes and the payload, and `infra.own` and
324
- the draft's `own` are gone with it. Whether a sender keeps a copy is read from its own feed. A message that carries
325
- `own`, which is every message 8.0 and 8.1 signed, is refused as `shape`, and so is a feed entry or history row that
326
- holds one.
327
- - A name with a Unicode format or control character, such as the right-to-left override U+202E or a zero width space,
328
- is refused as `shape`, since it can make one name look like another. The zero width non-joiner U+200C and the zero
329
- width joiner U+200D are allowed, because Persian and Indic names and joined emoji such as a family need them. The
330
- blank letters and marks are refused with them, since they show as nothing and have no honest use in a name: the
331
- braille blank U+2800, the Hangul fillers U+115F, U+1160, U+3164 and U+FFA0, the combining grapheme joiner U+034F and
332
- the Khmer inherent vowels U+17B4 and U+17B5. Variation selectors such as U+FE0F stay allowed, since emoji need them.
333
- - A name that reads as ending in `.eth` is refused as `shape`, since a page shows a verified ENS name where the
334
- chosen name would be and a chosen one could pass for it. The check reads a skeleton of the name, the name as a
335
- reader sees it: NFKD normalised, so fullwidth and other compatibility forms fold and an accented letter loses its
336
- accent, with every character Unicode's confusables data maps to the full stop read as `.` (the Arabic-Indic zeros,
337
- the Syriac full stops and the musical augmentation dot among them) and the ideographic full stop U+3002 too, since
338
- ENS reads it as one, without the Unicode default ignorable code points, the braille blank or any nonspacing or
339
- enclosing mark, with the letters the confusables data maps to `e`, `t` or `h` read as those letters (Cyrillic `е`,
340
- `т`, `н` and `Т`, Armenian `հ` and Greek `Ε`, `Τ`, `Η` among them), lower-cased, trimmed, and stripped of trailing
341
- spaces and dots. The name is refused when that skeleton ends in `.eth`. So `vitalik.eth`, `Vitalik.ETH`,
342
- `vitalik.eth.`, `vitalik.еth` (Cyrillic `е`), `vitalik٠eth` (Arabic-Indic zero), `vitalik.eth` followed by U+FE0F,
343
- `J.Eth` and `VITALIK.ETH` are all refused, while `vitalik eth`, `vitalik-eth`, `vitalik_eth`, `vitalik·eth`,
344
- `Kenneth`, `Seth`, `Ethan` and `J.Ethan` pass.
345
- - `SwarmChat` takes `user.walletLink`, `createChatMessage` takes `wl`, and `checkWalletLink` checks a link on its own.
346
- - A server on 8.x refuses every version 9 message as `shape`. Update the server before any page sends one.
347
-
348
- ## Changes in 8.1
349
-
350
- - `infra.own` signs every message with `own: true`, for a page whose write keeps a copy in the sender's own feed. Left
351
- out, messages say `own: false` as in 8.0.
352
-
353
- ## Changes in 8.0
354
-
355
- - The v8 message adds `via`, `own` and, for a signed-in sender, `idb`, its Swarm ID chat binding, all signed. A v7
356
- message, entry or history file fails its shape check, and nothing reads both.
357
- - `verifyChatMessage` replaces `checkChatMessage` and also checks the binding, returning the sender's `identity`.
358
- Both it and `parseChatMessage` require `{ origin }`, the site whose chat this is. A failed binding is refused with the
359
- new reason `binding`.
360
- - `MAX_MESSAGE_BYTES` is 3,072, so a long text fits beside a binding.
361
- - `createChatMessage` requires `via` and takes `own` and `idb`. `SwarmChat` requires `infra.via` and takes
362
- `user.binding`.
363
- - `ownFeedTopic` names the feed where a sender keeps its copies.
364
- - `MessageData.signedMessage` carries the whole signed message, for a page to verify. `chatTopic`, `signature` and
365
- `sentAt` are gone from `MessageData`, since `signedMessage` carries them as `topic`, `sig` and `ts`.
366
- - The binding is recovered through `@ethersphere/core-sdk`, which bee-js 13 already installs, now a dependency here.
367
-
368
- ## Changes in 7.3
369
-
370
- - A reader asks Bee for an address that came back missing no more than three times a minute, a feed slot and a note
371
- alike. Polling the next slot every second, as a reader that found no note did, kept that slot on Bee's skip list,
372
- so the first messages in a quiet room arrived 40 to 55 s late. A slot a note names is still read at once.
373
-
374
- ## Changes in 7.2
375
-
376
- - Readers follow a chat by its slot notes and no longer ask for the next feed slot before it exists, so a new message
377
- arrives in seconds rather than about forty. Opening needs no head lookup either. A server without notes is followed
378
- as 7.1 followed it.
379
- - The note protocol is exported from the root and from `./message` for the server. New settings `infra.noteSlotMs`
380
- and `infra.noteHeartbeatMs`.
381
-
382
- ## Changes from 6.x
383
-
384
- - Messages are signed over every field and verified by the server. 6.x signed four fields with a double prefix, and its
385
- check passed forged messages.
386
- - One inbox write per message. The write to the sender's own feed is gone, and so are `userTopic`, `additionalProps`,
387
- and the `enveloped` setting.
388
- - A bad message or history row is skipped, never a freeze. A slot no peer gives out is stepped past and read again.
389
- - No signature check per message on the read path, which cost 6.x about 36 ms of main thread per message on every open.
390
- - `sendMessage` takes three arguments, and a reaction resolves to `null`. `retrySendMessage` covers what
391
- `retryBroadcastUserMessage` did.
392
- - `stop` keeps listeners. New events: `STATUS`, `MESSAGE_SKIPPED` and `ERROR`. `CRITICAL_ERROR` means three failed
393
- openings in a row, and opening keeps trying.
394
- - `MessageData.index` is the feed index, `timestamp` is the server's receive time, and `sentAt` is new.
395
- - The GSOC mining script moved to the server repository, whose operator mines the inbox key.
396
- - The package is ESM first with a CommonJS build. The UMD bundle and the Node polyfills are gone.
374
+ was stamped by the server before the reader received it, so the reader can be at most `received - stamped` ahead of
375
+ the server. While notes are found, the reader trusts its own clock and moves reads earlier only when that bound proves
376
+ its clock runs behind. When no note is found for longer than a heartbeat, it waits out the whole bound from then on,
377
+ which is never early. A clock that runs behind sees every stamp as old as it expects, so its messages arrive as late as
378
+ it runs behind.
379
+
380
+ ## Changes
381
+
382
+ Each release's changes are in [CHANGELOG.md](CHANGELOG.md).
397
383
 
398
384
  ## Development
399
385
 
400
- Node 24 and pnpm 12, through `packageManager`.
386
+ Node 24 and pnpm 12, pinned by `.nvmrc` and `packageManager`.
401
387
 
402
388
  ```bash
403
389
  pnpm install
404
390
  pnpm test
405
391
  ```
406
392
 
407
- `lint` (oxlint, type-aware), `format` and `format:check` (oxfmt), `typecheck`, `test` (vitest) and `build` (vite, with
408
- types from `tsc`) are the scripts CI runs. `pnpm pack` builds before it packs.
393
+ CI runs `lint` (oxlint, type-aware), `format:check` (oxfmt), `typecheck`, `test` (vitest) and `build` (vite, with types
394
+ from `tsc`). `pnpm format` formats the code, and `pnpm pack` builds before it packs.
409
395
 
410
396
  ## License
411
397