@weaveprotocol/core 0.1.2 → 0.2.1

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.
Files changed (199) hide show
  1. package/README.md +322 -45
  2. package/dist/doors/doors.d.ts +139 -0
  3. package/dist/doors/doors.d.ts.map +1 -0
  4. package/dist/doors/doors.js +210 -0
  5. package/dist/doors/doors.js.map +1 -0
  6. package/dist/doors/index.d.ts +9 -0
  7. package/dist/doors/index.d.ts.map +1 -0
  8. package/dist/doors/index.js +7 -0
  9. package/dist/doors/index.js.map +1 -0
  10. package/dist/identity/contact-key.d.ts +68 -0
  11. package/dist/identity/contact-key.d.ts.map +1 -0
  12. package/dist/identity/contact-key.js +185 -0
  13. package/dist/identity/contact-key.js.map +1 -0
  14. package/dist/index.d.ts +16 -7
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +10 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/network/index.d.ts +2 -0
  19. package/dist/network/index.d.ts.map +1 -1
  20. package/dist/network/index.js +1 -0
  21. package/dist/network/index.js.map +1 -1
  22. package/dist/network/mailbox.d.ts +49 -0
  23. package/dist/network/mailbox.d.ts.map +1 -0
  24. package/dist/network/mailbox.js +125 -0
  25. package/dist/network/mailbox.js.map +1 -0
  26. package/dist/network/mesh.d.ts +6 -0
  27. package/dist/network/mesh.d.ts.map +1 -1
  28. package/dist/network/mesh.js +11 -1
  29. package/dist/network/mesh.js.map +1 -1
  30. package/dist/network/multi-signaling.d.ts +12 -2
  31. package/dist/network/multi-signaling.d.ts.map +1 -1
  32. package/dist/network/multi-signaling.js +86 -14
  33. package/dist/network/multi-signaling.js.map +1 -1
  34. package/dist/network/peer-auth.d.ts +50 -16
  35. package/dist/network/peer-auth.d.ts.map +1 -1
  36. package/dist/network/peer-auth.js +52 -15
  37. package/dist/network/peer-auth.js.map +1 -1
  38. package/dist/node/carrier.d.ts +87 -0
  39. package/dist/node/carrier.d.ts.map +1 -1
  40. package/dist/node/carrier.js +180 -59
  41. package/dist/node/carrier.js.map +1 -1
  42. package/dist/node/copy.d.ts.map +1 -1
  43. package/dist/node/copy.js +6 -3
  44. package/dist/node/copy.js.map +1 -1
  45. package/dist/node/host.d.ts +117 -0
  46. package/dist/node/host.d.ts.map +1 -0
  47. package/dist/node/host.js +192 -0
  48. package/dist/node/host.js.map +1 -0
  49. package/dist/node/index.d.ts +6 -2
  50. package/dist/node/index.d.ts.map +1 -1
  51. package/dist/node/index.js +1 -0
  52. package/dist/node/index.js.map +1 -1
  53. package/dist/node/node.d.ts.map +1 -1
  54. package/dist/node/node.js +958 -25
  55. package/dist/node/node.js.map +1 -1
  56. package/dist/node/space-runtime.d.ts +42 -25
  57. package/dist/node/space-runtime.d.ts.map +1 -1
  58. package/dist/node/space-runtime.js +734 -35
  59. package/dist/node/space-runtime.js.map +1 -1
  60. package/dist/node/types.d.ts +375 -3
  61. package/dist/node/types.d.ts.map +1 -1
  62. package/dist/privacy/space-encryption.d.ts +16 -0
  63. package/dist/privacy/space-encryption.d.ts.map +1 -1
  64. package/dist/privacy/space-encryption.js +40 -0
  65. package/dist/privacy/space-encryption.js.map +1 -1
  66. package/dist/query/engine.js +1 -1
  67. package/dist/query/engine.js.map +1 -1
  68. package/dist/query/types.d.ts +7 -0
  69. package/dist/query/types.d.ts.map +1 -1
  70. package/dist/query/types.js.map +1 -1
  71. package/dist/react/use-query.d.ts +1 -1
  72. package/dist/react/use-query.d.ts.map +1 -1
  73. package/dist/records/topics.d.ts +21 -0
  74. package/dist/records/topics.d.ts.map +1 -0
  75. package/dist/records/topics.js +86 -0
  76. package/dist/records/topics.js.map +1 -0
  77. package/dist/schema/collection-def.d.ts +7 -0
  78. package/dist/schema/collection-def.d.ts.map +1 -1
  79. package/dist/schema/collection-def.js +4 -0
  80. package/dist/schema/collection-def.js.map +1 -1
  81. package/dist/schema/expression.d.ts +2 -0
  82. package/dist/schema/expression.d.ts.map +1 -1
  83. package/dist/schema/expression.js +1 -0
  84. package/dist/schema/expression.js.map +1 -1
  85. package/dist/schemas/contacts.d.ts +214 -0
  86. package/dist/schemas/contacts.d.ts.map +1 -0
  87. package/dist/schemas/contacts.js +98 -0
  88. package/dist/schemas/contacts.js.map +1 -0
  89. package/dist/schemas/index.d.ts +2 -0
  90. package/dist/schemas/index.d.ts.map +1 -1
  91. package/dist/schemas/index.js +1 -0
  92. package/dist/schemas/index.js.map +1 -1
  93. package/dist/session/auth.d.ts.map +1 -1
  94. package/dist/session/auth.js +15 -3
  95. package/dist/session/auth.js.map +1 -1
  96. package/dist/session/connect.d.ts +25 -1
  97. package/dist/session/connect.d.ts.map +1 -1
  98. package/dist/session/connect.js +9 -2
  99. package/dist/session/connect.js.map +1 -1
  100. package/dist/session/hosting.d.ts +155 -0
  101. package/dist/session/hosting.d.ts.map +1 -0
  102. package/dist/session/hosting.js +160 -0
  103. package/dist/session/hosting.js.map +1 -0
  104. package/dist/space/account-registry.d.ts +6 -0
  105. package/dist/space/account-registry.d.ts.map +1 -1
  106. package/dist/space/account-registry.js +16 -4
  107. package/dist/space/account-registry.js.map +1 -1
  108. package/dist/space/notify.d.ts +88 -0
  109. package/dist/space/notify.d.ts.map +1 -0
  110. package/dist/space/notify.js +127 -0
  111. package/dist/space/notify.js.map +1 -0
  112. package/dist/space/pass.d.ts +6 -0
  113. package/dist/space/pass.d.ts.map +1 -1
  114. package/dist/space/pass.js +5 -2
  115. package/dist/space/pass.js.map +1 -1
  116. package/dist/space/roles.d.ts +71 -0
  117. package/dist/space/roles.d.ts.map +1 -1
  118. package/dist/space/roles.js +114 -0
  119. package/dist/space/roles.js.map +1 -1
  120. package/dist/space/space-access.d.ts +24 -0
  121. package/dist/space/space-access.d.ts.map +1 -1
  122. package/dist/space/space-access.js +24 -0
  123. package/dist/space/space-access.js.map +1 -1
  124. package/dist/space/space-manager.d.ts +35 -1
  125. package/dist/space/space-manager.d.ts.map +1 -1
  126. package/dist/space/space-manager.js +72 -25
  127. package/dist/space/space-manager.js.map +1 -1
  128. package/dist/storage/blob/memory.d.ts +9 -0
  129. package/dist/storage/blob/memory.d.ts.map +1 -0
  130. package/dist/storage/blob/memory.js +20 -0
  131. package/dist/storage/blob/memory.js.map +1 -0
  132. package/dist/storage/blob/s3.d.ts +16 -0
  133. package/dist/storage/blob/s3.d.ts.map +1 -0
  134. package/dist/storage/blob/s3.js +86 -0
  135. package/dist/storage/blob/s3.js.map +1 -0
  136. package/dist/storage/blob-store.d.ts +26 -0
  137. package/dist/storage/blob-store.d.ts.map +1 -0
  138. package/dist/storage/blob-store.js +2 -0
  139. package/dist/storage/blob-store.js.map +1 -0
  140. package/dist/storage/encrypted-adapter.d.ts +3 -3
  141. package/dist/storage/encrypted-adapter.js +3 -3
  142. package/dist/storage/folder-adapter.d.ts +8 -8
  143. package/dist/storage/folder-adapter.d.ts.map +1 -1
  144. package/dist/storage/folder-adapter.js +7 -7
  145. package/dist/storage/folder-reconcile.d.ts +12 -14
  146. package/dist/storage/folder-reconcile.d.ts.map +1 -1
  147. package/dist/storage/folder-reconcile.js +20 -14
  148. package/dist/storage/folder-reconcile.js.map +1 -1
  149. package/dist/storage/index.d.ts +0 -1
  150. package/dist/storage/index.d.ts.map +1 -1
  151. package/dist/storage/index.js +0 -1
  152. package/dist/storage/index.js.map +1 -1
  153. package/dist/storage/indexeddb-adapter.d.ts.map +1 -1
  154. package/dist/storage/indexeddb-adapter.js +8 -9
  155. package/dist/storage/indexeddb-adapter.js.map +1 -1
  156. package/dist/storage/mirror.d.ts +68 -0
  157. package/dist/storage/mirror.d.ts.map +1 -0
  158. package/dist/storage/mirror.js +174 -0
  159. package/dist/storage/mirror.js.map +1 -0
  160. package/dist/storage/segment.d.ts +20 -0
  161. package/dist/storage/segment.d.ts.map +1 -0
  162. package/dist/storage/segment.js +27 -0
  163. package/dist/storage/segment.js.map +1 -0
  164. package/dist/storage/storage-provider.d.ts +36 -40
  165. package/dist/storage/storage-provider.d.ts.map +1 -1
  166. package/dist/storage/storage-provider.js +208 -117
  167. package/dist/storage/storage-provider.js.map +1 -1
  168. package/dist/sync/index.d.ts +1 -1
  169. package/dist/sync/index.d.ts.map +1 -1
  170. package/dist/sync/index.js +1 -1
  171. package/dist/sync/index.js.map +1 -1
  172. package/dist/sync/negentropy.d.ts +68 -0
  173. package/dist/sync/negentropy.d.ts.map +1 -0
  174. package/dist/sync/negentropy.js +379 -0
  175. package/dist/sync/negentropy.js.map +1 -0
  176. package/dist/sync/sync-engine.d.ts +45 -13
  177. package/dist/sync/sync-engine.d.ts.map +1 -1
  178. package/dist/sync/sync-engine.js +290 -207
  179. package/dist/sync/sync-engine.js.map +1 -1
  180. package/dist/sync/sync-messages.d.ts +42 -32
  181. package/dist/sync/sync-messages.d.ts.map +1 -1
  182. package/dist/sync/sync-messages.js +1 -1
  183. package/dist/sync/sync-messages.js.map +1 -1
  184. package/dist/types.d.ts +9 -0
  185. package/dist/types.d.ts.map +1 -1
  186. package/dist/types.js.map +1 -1
  187. package/dist/utils/hash.d.ts +9 -0
  188. package/dist/utils/hash.d.ts.map +1 -1
  189. package/dist/utils/hash.js +33 -0
  190. package/dist/utils/hash.js.map +1 -1
  191. package/package.json +8 -3
  192. package/dist/storage/mst.d.ts +0 -121
  193. package/dist/storage/mst.d.ts.map +0 -1
  194. package/dist/storage/mst.js +0 -402
  195. package/dist/storage/mst.js.map +0 -1
  196. package/dist/sync/anti-entropy.d.ts +0 -49
  197. package/dist/sync/anti-entropy.d.ts.map +0 -1
  198. package/dist/sync/anti-entropy.js +0 -62
  199. package/dist/sync/anti-entropy.js.map +0 -1
package/README.md CHANGED
@@ -4,6 +4,11 @@ A peer-to-peer data protocol for the browser. You own your identity as a
4
4
  written-down code, keep your data in signed records that sync directly between
5
5
  devices, and every app is a view onto that data rather than its owner.
6
6
 
7
+ > **Building a client, or an agent that needs the architecture?** The
8
+ > protocol is specified in [docs/spec](docs/spec/README.md): wire formats,
9
+ > what is signed, and what every peer must check. The tests are its
10
+ > executable half.
11
+
7
12
  ## Architecture
8
13
 
9
14
  ```
@@ -11,11 +16,11 @@ devices, and every app is a view onto that data rather than its owner.
11
16
  │ Applications │
12
17
  ├──────────────┬───────────┬────────────┬──────────┬───────────┤
13
18
  │ Accounts │ Spaces │ Validation │ Privacy │ Sync │
14
- │ seed, vault, │ roles, │ crypto → │ AES-GCM │ MST anti- │
15
- │ root signer, │ members × │ structural │ per │ entropy │
16
- │ UCAN, pairing│ pub/priv │ → UCAN │ space │ gossip │
19
+ │ seed, vault, │ roles, │ crypto → │ AES-GCM │ Negentropy│
20
+ │ root signer, │ members × │ structural │ per │ per │
21
+ │ UCAN, pairing│ pub/priv │ → UCAN │ space │ collection│
17
22
  ├──────────────┴───────────┴────────────┴──────────┴───────────┤
18
- │ Storage: Merkle Search Tree over a StorageAdapter │
23
+ │ Storage: signed versions + plain index over an adapter │
19
24
  │ IndexedDB (per origin) · data folder (shared by origins) │
20
25
  ├──────────────────────────────────────────────────────────────┤
21
26
  │ Network: WebRTC data channels │
@@ -71,7 +76,7 @@ const ucan = await root.delegate({
71
76
  const spaces = createSpaceManager(await createIndexedDBAdapter('my-app/registry'));
72
77
  const { space } = await spaces.create({ name: 'Notes', visibility: 'public', creator: me.did });
73
78
 
74
- // 4. A signed record, stored in that space's own Merkle tree
79
+ // 4. A signed record, stored in that space's own store
75
80
  const storage = createStorageProvider(await createIndexedDBAdapter(`my-app/space/${space.id}`));
76
81
  const signed = await createSigner(provider).sign(
77
82
  createExpression({
@@ -222,6 +227,105 @@ await calls.answer(ringing.id); calls.setMuted(true); await calls.shareScreen(
222
227
 
223
228
  The messages, and the reasoning, are at the top of `src/calls/calls.ts`.
224
229
 
230
+ ### Contacts
231
+
232
+ A DID is a **name, not an address**: it's on everything you sign, so knowing
233
+ it must not be enough to reach you. What lets two people reach each other is a
234
+ space they share. So **a contact is a private space for two**: records you
235
+ write there wait for the other person, and live messages reach them when
236
+ they're online. There's no directory to look people up in, and no inbox
237
+ strangers can knock on — unless you open a door (below).
238
+
239
+ ```typescript
240
+ // In a space you share with Anna — the book club — ask her to add you.
241
+ const { space } = await node.contacts.ask(club.id, anna, { note: "it's Leif from book club" });
242
+
243
+ // On Anna's side:
244
+ const [request] = await node.contacts.requests(club.id); // { from, name, note, pairSpace, … }
245
+ await node.contacts.accept(club.id, request.key); // joins the space for two, adds Leif
246
+
247
+ await node.contacts.list(); // [{ did, name, space, note, blocked }]
248
+ await node.contacts.put({ did, name: 'Anna K' }); // what you call them — only you see it
249
+ await node.contacts.remove(anna); // off the list, and out of your space with her
250
+ await node.contacts.block(anna); // and her requests are hidden in every space
251
+ await node.contacts.others(anna); // anyone else in your space with her: [] unless someone was let in
252
+ ```
253
+
254
+ - **The list** is one `std.contact` per person, in a **contacts space**
255
+ derived from the account key the way the account registry is: every device
256
+ of the account has it, nobody else can find it, and it's hidden from
257
+ `spaces.list`. `contacts.space()` gives its id. A carrier carries it, so a
258
+ restore brings the list back.
259
+ - **Asking** (`ask`) makes a private space for the two of you, puts them on
260
+ your list with it, and posts a `std.contact-request` in the shared space:
261
+ the new space's invite, **sealed with their contact key**. The other members
262
+ can see that you asked, not what. The seal is bound to the space it was
263
+ posted in and to who asked whom, so a request copied into another space, or
264
+ re-posted by someone else, doesn't open. Joining is the answer: there's no
265
+ reply record.
266
+ - **The contact key** is a P-256 key for encryption, derived from the seed
267
+ under its own label (`deriveContactKeyBytes`), so it's the same on every
268
+ device and comes back with the recovery code. Its public half goes on your
269
+ profile in every space you write in (`spaces.profiles(id)[n].contactKey`),
270
+ and only counts on a profile signed by your own account. An app without the
271
+ key never takes it off: a profile keeps the newest key it was given.
272
+ - **Conversation or group** is decided by how the space was made, not by how
273
+ many people are in it: a space is your conversation with Anna because your
274
+ list says so. A space made with "New space" and shared with one person is
275
+ never one. If a third account turns up in a space for two, `others` says so,
276
+ and an app can offer to start a new space with just the two of you, or a new
277
+ group with everyone.
278
+
279
+ **What an app gets.** A home gives the contacts to an app that asks with
280
+ `contacts: true`: the contacts space as one more space in its grant, and the
281
+ contact key, which opens requests sent to you. Asking someone and accepting
282
+ also make or join a space, so those need `scope: 'account'`, which includes
283
+ the contacts. Agents never get them.
284
+
285
+ ### Doors
286
+
287
+ Someone you share no space with can still ask to become your contact — if you
288
+ give them a **door**. A door is a code you hand out on purpose (a link, a QR
289
+ code, a line in your bio) and can close. It names a key derived from your
290
+ contact key and the relays whose mailboxes hold knocks on it, and nothing
291
+ about who you are.
292
+
293
+ ```typescript
294
+ const door = await anna.doors.open(); // { id, code, relays, … }
295
+ share(`https://chat.example/#door=${door.code}`);
296
+
297
+ // Leif, who has never shared a space with Anna, pastes the link:
298
+ await leif.doors.knock(link, { note: 'We met at the gig' }); // a space for two, its invite sealed to the door
299
+
300
+ // Anna, whenever she's next online:
301
+ const [knock] = await anna.doors.knocks(); // { from, name, note, pairSpace, … } — `from` is proven
302
+ await anna.doors.accept(knock.id); // joins; Leif is a contact, and she is his once it syncs
303
+ await anna.doors.close(door.id); // the code leads nowhere now; contacts stay
304
+ ```
305
+
306
+ - **The relay keeps a mailbox**, the one thing it holds: a sealed blob under a
307
+ hash of the door's signing key, for up to 14 days. It sees addresses, as it
308
+ does for any socket, but not whose door it is, which account knocked, or what
309
+ they said. A door names up to three relays and a knock goes to all of them,
310
+ so no one relay can shut it.
311
+ - **A knock proves who knocked** before anyone joins anything: it's signed by
312
+ the knocker's session key under a note for their whole account, like a
313
+ record, bound to the door it was left at and to when the relay took it.
314
+ - **An answer proves who opened.** Accepting writes an answer in the space for
315
+ two, signed with the door's key: that, not joining, makes the owner the
316
+ knocker's contact, and the invite is closed behind them.
317
+ - **The door is not the account.** Its key is not your contact key, so nobody
318
+ can link a door to your profile in any space. Every device with the contact
319
+ key opens the same doors.
320
+ - **Spam** is capped at the mailbox (64 knocks a door; 4 a door and 30 in all
321
+ an hour from one address). `dismiss` lets one knock go without blocking,
322
+ blocking hides someone's knocks on every door, and a flooded door is cleared
323
+ by its owner (`clear`), so its code keeps working.
324
+
325
+ Next: handles that lead to a door, so `@anna.bsky.social` works where a code
326
+ does (planned in [07 — Doors](docs/spec/07-doors.md), Names). The full design is
327
+ [docs/spec/07-doors.md](docs/spec/07-doors.md).
328
+
225
329
  ## Signing in — the element, and React
226
330
 
227
331
  Getting to a node takes a sign-in flow: where the data lives (a pod or this
@@ -362,12 +466,51 @@ the account's list on every device.
362
466
  An app that is a view onto *everything* — like the example — asks for
363
467
  `scope: 'account'`: a note for every space, plus the key the account's space
364
468
  list is derived from, so it sees every space and can make and join them. It
469
+ gets the contacts too (see Contacts, above). It
365
470
  still never holds the seed: it cannot sign in anywhere as the account, change
366
471
  its password or passkeys, or keep access past the note's date.
367
472
 
368
473
  The home side is `receiveConnectRequest()` and `auth.grant(…)`; see
369
474
  [home/README.md](home/README.md).
370
475
 
476
+ ### Apps hold what they use — keepers hold the rest
477
+
478
+ A space can name its **keepers**: nodes that hold every record of it, like a
479
+ host or the extension (`node.spaces.setKeepers`, for someone who manages the
480
+ space; the account's carriers are named by themselves in every space it
481
+ manages). Once it does, an app connected to the account home holds only the
482
+ collections it uses, besides the space's own (`sys.*`):
483
+
484
+ - a query says what it needs — its collection and every `include … from` —
485
+ and those start syncing from any peer that has them;
486
+ - `result.complete` is false until they have caught up with a node holding
487
+ the whole space: show "Loading…", not "Nothing here";
488
+ - the app's own writes stay **pending** until enough keepers say they have
489
+ them (`stored`): the space's `copies`, or 2, never more than it names.
490
+ Nothing pending is ever dropped;
491
+ - a collection no query has touched for `unusedAfterDays` (30) is dropped,
492
+ unless the app declared it (`cache.collections`). It syncs back when needed.
493
+
494
+ A collection can also name **topic fields** (`topics: ['channel', 'mentions']`).
495
+ Each record then carries, on its outside, a keyed hash of each of those values
496
+ (`tags`), so a keeper that can't read the record can still match "in
497
+ #design" or "mentions me" — learning which records share a topic, never which
498
+ topic. `node.collections.tag(space, collection, field, value)` gives the tag to
499
+ match; anyone who can read a record checks its tags and refuses a mismatch.
500
+
501
+ **"Notify me when…"** (`node.notifications`, and the home's section of that
502
+ name): new records in a collection, in every space or some, perhaps only with
503
+ a topic value ("mentions me"), perhaps only other people's. The account
504
+ registry keeps each one sealed; every carrier gets it with the value replaced
505
+ by each space's tag, so the Weave extension notices matching records as they
506
+ arrive and shows a notification — the space, your label, the time — without
507
+ being able to read them, or what you asked for.
508
+
509
+ A space that names no keeper is held whole, as before: then the app may be one
510
+ of its copies. How much to hold is each node's choice (`NodeConfig.cache`;
511
+ `startConnectedNode` turns it on unless given `cache: false`); only how
512
+ keepers confirm is protocol. `spaces.status(id)` shows `holds` and `pending`.
513
+
371
514
  ## Modules
372
515
 
373
516
  ### Identity (`@weaveprotocol/core/identity`)
@@ -484,16 +627,27 @@ Typed, signed data expressions using [Standard Schema](https://standardschema.de
484
627
 
485
628
  ### Storage (`@weaveprotocol/core/storage`)
486
629
 
487
- Local-first storage with Merkle Search Tree for efficient sync.
630
+ Local-first storage: signed versions, and plain entries saying which is current
631
+ and which versions each collection keeps — the set sync compares.
488
632
 
489
633
  | Export | Description |
490
634
  |--------|-------------|
491
- | `createStorageProvider()` | MST-backed expression storage; `compact()` deletes tree nodes the root no longer reaches |
635
+ | `createStorageProvider()` | Expression storage: current, first and retained versions (`r/`, `g/`, `h/`), and every kept version by collection (`i/`) for sync; `fingerprint()` is equal on two stores keeping the same versions |
492
636
  | `createIndexedDBAdapter()` | IndexedDB storage adapter, scoped to this origin |
493
637
  | `createFolderAdapter()` | A user-picked directory, shared by every origin given access |
494
638
  | `createEncryptedAdapter()` | Seals chosen keys (space records, space keys) at rest |
495
- | `reconcileFolder()` | Rebuilds the tree after another writer touched a folder |
496
- | `insertIntoMST()` / `listMSTEntries()` | Direct MST operations; a listing can stop at a key prefix |
639
+ | `reconcileFolder()` | Places the files another writer added to a folder, and drops entries whose file is gone |
640
+ | `createMirror()` | Keeps a space in a dumb file store too, synced like a peer that never runs code |
641
+ | `createS3BlobStore()` / `createMemoryBlobStore()` | File stores a mirror can use: any S3-compatible bucket (R2, B2, MinIO, AWS), or memory |
642
+
643
+ **Mirrors.** A bucket or an app folder can hold a space: each writer (one store
644
+ on one device, with a random id) only ever adds immutable segments in its own
645
+ folder, named by a counter and the hash of their bytes — so nothing is written
646
+ twice and nothing needs a lock. A segment holds versions exactly as they travel,
647
+ private bodies still sealed, and everything read back passes the same gates as a
648
+ peer's records: the store can hide things, not forge them. What a writer knows
649
+ the store holds, it never uploads again; a writer compacts its own segments
650
+ into fewer. Merging is the protocol's own — a set of versions that only grows.
497
651
 
498
652
  ### Spaces
499
653
 
@@ -523,7 +677,7 @@ const { space, key } = await spaces.create({
523
677
  });
524
678
  ```
525
679
 
526
- Give each space its own storage and its own MST and a peer you share one list
680
+ Give each space its own storage and its own sync and a peer you share one list
527
681
  with learns nothing about the others.
528
682
 
529
683
  #### Who may write: roles, and a history every peer replays
@@ -543,8 +697,9 @@ and roles ranked below you, and give out roles up to your own rank. Two people
543
697
  at the same rank can never remove each other, only themselves — so the creator
544
698
  **hands over** by giving someone their role, then leaving, and the space goes on.
545
699
 
546
- Roles, members, invites, revoked notes and collection definitions are records
547
- (`sys.role`, `sys.member`, `sys.invite`, `sys.revoke`, `sys.collection`), and
700
+ Roles, members, invites, revoked notes, collection definitions and changes of a
701
+ private space's key are records (`sys.role`, `sys.member`, `sys.invite`,
702
+ `sys.revoke`, `sys.collection`, `sys.key`), and
548
703
  every record written anywhere names the latest of them its writer knew, as
549
704
  `seen`. That makes the **access history** a small graph, which every peer
550
705
  replays the same way (`space/roles.ts`): a change comes after what it saw;
@@ -586,15 +741,35 @@ connect under someone else's name, and in a private space with the read key
586
741
  too, checked against its public half. A stranger who learns a space's id, or a
587
742
  relay that sees its room, gets no ciphertext. A peer-to-peer handshake also
588
743
  signs both ends' DTLS fingerprints, so a relay that swapped in its own offer to
589
- sit in the middle is caught. Roles govern writing only: someone removed keeps
590
- the read key until the space's key changes for everyone (BLOCK-14 §2).
744
+ sit in the middle is caught.
745
+
746
+ **Removing someone from a private space changes its key**, the way Keybase
747
+ changes a team's key. Whoever manages the space, seeing someone lose their
748
+ place (removed, left, or their role deleted), makes a new key by itself and
749
+ writes a `sys.key` record: the new key's id and public read key, and every
750
+ earlier key sealed under the new one. It is part of the access history, so two
751
+ new keys made apart resolve like any other change, and only `manage` may make
752
+ one. Each member's copy goes in a `sys.box`, sealed to their **member key**: a
753
+ key pair per account per space, derived from the account's vault key, whose
754
+ public half each member publishes in the clear (`sys.memberkey`). An account
755
+ home hands an app the member keys of exactly the spaces it grants.
756
+
757
+ From then on new records are sealed with the new key. Someone removed keeps what
758
+ they could already read, and nothing after it. Holding the current key opens
759
+ every earlier one, so a newcomer reads the space's past. A member who was away
760
+ when the key changed still holds only the older read key: they prove that, and
761
+ send their note sealed under the older key, and a peer holding it lets them in
762
+ if they are still a member. A host with no key takes the current read key only.
763
+ View-only links made before the change stop working; share a new one.
764
+ `node.spaces.changeKey(space)` does the same by hand, for a lost device.
591
765
 
592
766
  A space's **id is the hash of what is fixed at creation**: creator, visibility,
593
767
  starting roles and which one the creator holds, time, a random nonce and the
594
- read key (the name is left out, so it can change). `join` refuses an invite
595
- whose space does not hash to its id, or whose key is not the one the space
596
- names — so whoever passes an invite on cannot change who started the space, or
597
- with which roles.
768
+ first read key (the name is left out, so it can change). `join` refuses an
769
+ invite whose space does not hash to its id — so whoever passes an invite on
770
+ cannot change who started the space, or with which roles. The key an invite
771
+ carries may be a later one; its id is its hash, so it is either the key the
772
+ history names or one that opens nothing.
598
773
 
599
774
  **Spaces describe themselves.** A space stores its collections' definitions —
600
775
  name, title, description and a JSON Schema — as signed records in
@@ -774,23 +949,36 @@ tell a Weave peer from anyone else, so set coturn's own quotas (`user-quota`,
774
949
  `total-quota`, `max-bps`). TURN only forwards encrypted packets; it can't
775
950
  see or hear a call.
776
951
 
952
+ The relay's Docker image (`server/`, deployed to Fly) runs coturn beside it when
953
+ `TURN_SECRET` is set, already capped that way: `server/fly.toml` says how.
954
+
777
955
  Only peers already in a room hear about a newcomer, so exactly one side creates
778
956
  the offer and the two never collide.
779
957
 
780
958
  ### Sync (`@weaveprotocol/core/sync`)
781
959
 
782
- Anti-entropy gossip protocol for eventual consistency.
960
+ Range-based set reconciliation (Negentropy, as Nostr's NIP-77), one collection at a time.
783
961
 
784
962
  | Export | Description |
785
963
  |--------|-------------|
786
- | `createSyncEngine()` | Automatic MST reconciliation with heartbeat |
787
- | `verifyNode()` / `unknownChildren()` | The pieces of a tree walk |
788
-
789
- Two peers compare roots — equal means identical, one round trip. Otherwise each
790
- walks the other's tree from the root, skipping every subtree already in its own,
791
- so cost follows the size of the difference: one changed entry in 10,000 costs
792
- about 37 KB on the wire, where sending every key cost 508 KB. Whether a subtree
793
- is already here is one lookup in the store, not a read of the whole local tree.
964
+ | `createSyncEngine()` | Reconciles a space with its peers, with a heartbeat |
965
+ | `createReconciler()` / `ItemSet` / `fingerprintOf()` | Negentropy itself, wire-compatible with the reference implementation |
966
+
967
+ A peer says hello with a fingerprint of each collection it keeps — in essence
968
+ the sum of its version ids. Equal fingerprints mean the same versions, and that
969
+ collection is done. For each one that differs, the peer whose id sorts first
970
+ compares fingerprints of ever smaller ranges with the other until both know
971
+ exactly which versions each lacks; then it asks for what it lacks and sends what
972
+ the other does. Cost follows the difference, not the size: one new version
973
+ among 2,000 syncs in under 8 KB, all told. Nothing is stored for sync beyond
974
+ the versions' own index entries: the sets and sums live in memory, updated by
975
+ every change and read again when another writer (a tab, a folder) changed the
976
+ store.
977
+
978
+ Each hello also says what the peer holds — `all`, or some collections — and
979
+ two peers reconcile only the collections both hold. A version for a collection
980
+ a node doesn't hold is passed over; one it takes in, it tells the sender it has
981
+ (`stored`). One peer's messages are handled in the order they came.
794
982
 
795
983
  The engine's `validate` hook is the seam where the validation engine sits.
796
984
  Expressions a peer sends are only committed if it accepts them; the rest are
@@ -862,7 +1050,7 @@ interface StorageAdapter {
862
1050
  - `createIndexedDBAdapter(name)` — works in every browser. Origin-scoped.
863
1051
  - `createFolderAdapter(directory, namespace)` — a directory the user picked, via the File System Access API. **Not** origin-scoped. Chrome, Edge and Opera on the desktop.
864
1052
 
865
- The always-on node (`weave run`) uses the folder adapter on disk, in the same layout. **Planned**: mirrors, which keep a space in storage the user already pays for (a Dropbox app folder, Drive, S3) and sync with it like a peer — see `docs/blocks/BLOCK-03-mirrors.md`. OPFS is not on the list: it is origin-private, so it would inherit exactly the limitation a data folder exists to avoid.
1053
+ The always-on node (`weave run`) uses the folder adapter on disk, in the same layout. **Planned**: mirrors, which keep a space in storage the user already pays for (a Dropbox app folder, Drive, S3) and sync with it like a peer — see [05 — Sync and storage](docs/spec/05-sync-and-storage.md). OPFS is not on the list: it is origin-private, so it would inherit exactly the limitation a data folder exists to avoid.
866
1054
 
867
1055
  ### Data folders — storage that outlives the origin
868
1056
 
@@ -877,7 +1065,7 @@ A directory handle is the exception. Each origin asks for permission once, and b
877
1065
  accounts.json name, DID and id of each account (readable without unlocking)
878
1066
  accounts/<id>/account.json that account's seed, encrypted once per way of unlocking it
879
1067
  accounts/<id>/stores/<namespace>/
880
- kv/<key> MST nodes, the root pointer, space records (sealed)
1068
+ kv/<key> index entries (current, first, retained, by collection), space records (sealed)
881
1069
  expressions/<cid>.json one signed record per file
882
1070
  ```
883
1071
 
@@ -910,11 +1098,11 @@ const registry = createEncryptedAdapter(
910
1098
  );
911
1099
  ```
912
1100
 
913
- **Expressions are the truth; the MST is an index over them.** That inversion is what lets several writers share one folder without taking a lock. Every expression file is named by its own content hash, so concurrent writers can only ever add files that agree; the single mutable thing, the root pointer, is derived state that either side can rebuild. `reconcileFolder()` rebuilds it — call it on an interval, on window focus, or after a sync round, since the web has no filesystem change notification.
1101
+ **Expressions are the truth; the entries are an index over them.** That inversion is what lets several writers share one folder without taking a lock. Every expression file is named by its own content hash, so concurrent writers can only ever add files that agree; the one thing both can overwrite, which version a record's current entry names, is derived state either side can put right. `reconcileFolder()` does — it places every file that appeared, which corrects an entry a race left wrong — so call it on an interval, on window focus, or after a sync round, since the web has no filesystem change notification.
914
1102
 
915
1103
  Two consequences worth having:
916
1104
 
917
- - **The folder is the account.** Copy it to a USB stick and it is your whole identity. Put it in iCloud, Dropbox or Syncthing and several devices converge with no relay at all — the folder becomes a second transport alongside WebRTC, and both meet in the same anti-entropy merge.
1105
+ - **The folder is the account.** Copy it to a USB stick and it is your whole identity. Put it in iCloud, Dropbox or Syncthing and several devices converge with no relay at all — the folder becomes a second transport alongside WebRTC, and both meet in the same merge: the set of versions only grows, and the version rule picks the same current one everywhere.
918
1106
  - **It changes nothing about the mesh.** A folder-backed node is an ordinary peer that happens to be durable and readable by several origins — an availability role, never an authority one. Where there is no folder (Safari, Firefox, mobile) a node keeps an origin-scoped replica and gossips exactly as before.
919
1107
 
920
1108
  ### Locking the folder
@@ -939,7 +1127,7 @@ await accounts.write(account, withWrap(vault, wrap));
939
1127
 
940
1128
  `deviceWrapsFor(vault, rpId)` says which wraps this origin can even attempt; the rest name keys it cannot reach. The gate is enforced in application code rather than by cryptography — see `src/identity/device-key.ts` for what that does and does not protect against. The recovery code needs no wrap, because it *is* the seed in printable form — it opens the folder anywhere, including on a phone or in a browser with no File System Access API, and it is shown once and stored nowhere.
941
1129
 
942
- `createEncryptedAdapter` seals `space:`, `spacekey:`, `spaceinvite:` and `spacerole:` values under the vault key, which is what makes a private space genuinely unreadable to someone holding the folder. It is scoped deliberately narrowly: expressions and MST nodes pass through, so what stays legible is each record's author, timestamp and collection, plus anything in a space its owner made public. Sealing those too would mean an opaque blob store, which would cost the property that makes a folder worth having.
1130
+ `createEncryptedAdapter` seals `space:`, `spacekey:`, `spaceinvite:` and `spacerole:` values under the vault key, which is what makes a private space genuinely unreadable to someone holding the folder. It is scoped deliberately narrowly: expressions and index entries pass through, so what stays legible is each record's author, timestamp and collection, plus anything in a space its owner made public. Sealing those too would mean an opaque blob store, which would cost the property that makes a folder worth having.
943
1131
 
944
1132
  ### Where the root key lives
945
1133
 
@@ -991,6 +1179,18 @@ space with is a peer in that one only.
991
1179
  **Several relays**, so there is no single phone book — a peer announced by two
992
1180
  of them is announced upward once, and replies go back the way they arrived.
993
1181
 
1182
+ **The space says where it meets**, like a Nostr relay list, but the space's
1183
+ own. `sys.relays` is part of the access history, so only someone who manages
1184
+ the space changes it (`node.spaces.setRelays`), and a new space names its
1185
+ creator's relays by itself. Invites carry the list, so a joiner whose app uses
1186
+ other relays reaches the space before anything has synced. Every member then
1187
+ joins the space's room on those relays as well as their own
1188
+ (`mesh.useRelays(room, relays)`); relays only one space names are joined for
1189
+ that space's room alone, and dropped once no open space names them. Moving a
1190
+ space to another relay, a self-hosted one say, is one change, and everyone
1191
+ follows. No DHT: browsers can't be DHT nodes, and would need a relay to reach
1192
+ one anyway.
1193
+
994
1194
  **Peers introduce peers**, so a relay is only needed for the *first* connection.
995
1195
  Once you are connected to someone, their data channel carries signalling for the
996
1196
  peers you have not met: `__peers` says who I can see, `__signal` carries
@@ -1089,6 +1289,27 @@ it doubles as a relay), and `weave mcp` to hand the same operations to an agent.
1089
1289
  It reads and writes the same data folder layout a browser does. See
1090
1290
  [cli/README.md](cli/README.md).
1091
1291
 
1292
+ **Hosting.** `weave host` keeps many accounts' spaces online without being able
1293
+ to read them: it is the extension's carrier (`createCarrierNode`) with one carry
1294
+ space per paying account (`createHostNode`), each space held once however many
1295
+ members pay for it. A subscription is a key the account makes and keeps in its
1296
+ registry (`sys.hosting`), so every device signs as it; `node.hosting.use(url)`
1297
+ starts one, and whichever device notices it is paid hands the host the carry
1298
+ space. Every call to the host is signed over method, path, time and body.
1299
+ The home knows nothing about payment ([06 — Nodes, sessions and apps](docs/spec/06-nodes-and-sessions.md), Hosts): a host describes itself at
1300
+ `/.well-known/weave-host` (like a Nostr relay's NIP-11 document), signs every
1301
+ status it gives — the home keeps the latest in the registry as the person's
1302
+ proof — and takes payments on its own pay page, which `node.hosting.payPage(url)`
1303
+ links to with a signature by the subscription key, valid for an hour at that
1304
+ host only. On the page: Stripe Checkout and the Customer Portal (card, Apple
1305
+ Pay, Google Pay), whose webhook moves a paid-until date taken from Stripe's own
1306
+ billing period; and USDC from a crypto wallet straight to the host's address,
1307
+ checked on the network by the host. Past the date: a grace period, then the host drops
1308
+ the spaces and deletes its copy. With a bucket (`WEAVE_S3_*`) the host's disk
1309
+ is only a cache — every carried space and the subscription list live in the
1310
+ bucket, sealed, and a new machine starts from it. The account home's Settings
1311
+ has **Keep my spaces online**.
1312
+
1092
1313
  ## Example app
1093
1314
 
1094
1315
  `example/` is the Weave website — a landing page for developers at `/`, and
@@ -1101,11 +1322,44 @@ npm install && (cd example && npm install) && (cd home && npm install) # the C
1101
1322
  npm run dev
1102
1323
  ```
1103
1324
 
1104
- That starts three things: the example app on 5173, the account home it
1105
- connects to on 5174, and an always-on node on port 8787 that is also the relay.
1106
- The node gets a throwaway identity on first run (`cli/.env.dev`, data in
1107
- `.weave-dev/`), and `example/.env.development` points the app at the other two.
1108
- Override either in a `.env.local`.
1325
+ That starts everything, with coloured output per part, and Ctrl-C stops it all:
1326
+
1327
+ | Part | Where | What |
1328
+ |---|---|---|
1329
+ | app | http://localhost:5173 | The example app |
1330
+ | home | http://localhost:5174 | The account home it connects to |
1331
+ | node | port 8787 | An always-on node that is also the relay; a throwaway identity on first run (`cli/.env.dev`, data in `.weave-dev/`) |
1332
+ | host | http://localhost:8788 | `weave host`, what "Keep my spaces online" uses, with its pay page at `/pay`; settings in `cli/.env.host.dev` |
1333
+ | stripe | — | Only when you add a Stripe test key (below): forwards Stripe's webhooks to the host |
1334
+
1335
+ `example/.env.development` and `home/.env.development` point the app and home
1336
+ at the rest. Override any of them in a `.env.local`, and the host in
1337
+ `cli/.env.host.local`.
1338
+
1339
+ **Trying hosting and payments.** In the home: Settings, **Keep my spaces
1340
+ online**, **Keep online** (the dev host is filled in), then **Payment**, which
1341
+ opens the host's pay page in a new tab. What you can pay with there:
1342
+
1343
+ - **A browser wallet, on by default.** Payments go to Base Sepolia, a test
1344
+ network: test money only. In MetaMask (or any browser wallet), get test ETH
1345
+ for the fee from a Base Sepolia faucet (Coinbase's, or Alchemy's) and test
1346
+ USDC from faucet.circle.com (choose Base Sepolia). Pay, and the pay page
1347
+ says "Payment received"; back in the home's tab, the host shows "paid until"
1348
+ and takes your spaces. To see payments arrive, set your own address as
1349
+ `WEAVE_WALLET_ADDRESS` in `cli/.env.host.local`.
1350
+ - **A card, in Stripe's test mode.** In `cli/.env.host.local`, add
1351
+ `STRIPE_SECRET_KEY=sk_test_…` and the price ids of a monthly and a yearly
1352
+ recurring test price (`STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, from the Stripe
1353
+ dashboard in test mode). With the Stripe CLI installed, `npm run dev`
1354
+ forwards Stripe's webhooks to the host by itself. Pay with card
1355
+ 4242 4242 4242 4242, any future date, any CVC.
1356
+ - **Phone wallets, by QR code.** Add `WEAVE_WALLETCONNECT_PROJECT_ID` (free at
1357
+ dashboard.reown.com, with localhost allowed in the project) to
1358
+ `cli/.env.host.local`; `npm run dev` builds what the pay page needs. The
1359
+ wallet has to be on Base Sepolia too.
1360
+
1361
+ `npm test` covers the same paths without any of this: a fake network, fake
1362
+ Stripe calls, and the home's side against a real host.
1109
1363
 
1110
1364
  The example never signs anyone in: "Connect with Weave" opens the home, where
1111
1365
  you make an account or sign in, and allow the example your whole account. Its
@@ -1129,7 +1383,7 @@ between pods, pair a phone by QR code, and see which apps you connected. In the
1129
1383
  example: make private or public spaces, just yours or with people you invite, and share one with a
1130
1384
  friend via an invite link. Everyone in a space is shown by the name
1131
1385
  they gave. Every record is signed by a delegated session key, stored in that
1132
- space's MST, encrypted first if the space is private, and gossiped to peers over
1386
+ space's own store, encrypted first if the space is private, and gossiped to peers over
1133
1387
  WebRTC; a record says *verified* once its signature and its delegation chain
1134
1388
  check out here, and *encrypted* when it arrived encrypted.
1135
1389
 
@@ -1190,7 +1444,23 @@ the agents it finds, and they start `weave mcp` themselves: a node of its own,
1190
1444
  over WebRTC (`node-datachannel`), that follows the account and keeps working
1191
1445
  with every tab closed. What it writes shows "via agent", and every peer
1192
1446
  ignores an agent changing collections, who may do what, or the account's own
1193
- list of spaces. See BLOCK-20.
1447
+ list of spaces. See [06 — Nodes, sessions and apps](docs/spec/06-nodes-and-sessions.md), Agents.
1448
+
1449
+ ## Still to do in the library
1450
+
1451
+ What the protocol still has planned is in [the spec](docs/spec/README.md), under
1452
+ **Planned** in each part. Library work that isn't protocol:
1453
+
1454
+ - **Typed queries, further.** Typed field paths and operator values in
1455
+ `where`, a misspelled collection name as a compile error, typed link roles,
1456
+ types generated from a space's stored definitions, and a dev-time warning
1457
+ when declared schemas differ from the space's catalogue.
1458
+ - **Typed collections.** One TypeScript builder that emits the schema, the
1459
+ rules and the types, and typed handles (`node.use(space, Poll)`).
1460
+ - **Definitions that update themselves.** `useSchemas` and `addApp` applying
1461
+ harmless changes, with `differences()` in `src/schemas/apps.ts` replaced by
1462
+ the planned `compare` ([02](docs/spec/02-records.md), compatible definitions).
1463
+ - **Web components** for the standard schemas.
1194
1464
 
1195
1465
  ## Releasing
1196
1466
 
@@ -1201,11 +1471,18 @@ workspace) are released together, always with the same version:
1201
1471
  npm run release
1202
1472
  ```
1203
1473
 
1204
- It typechecks and runs the tests, then [bumpp](https://github.com/antfu-collective/bumpp)
1474
+ It checks you're logged in to npm first (and runs `npm login` if not),
1475
+ typechecks and runs the tests, then [bumpp](https://github.com/antfu-collective/bumpp)
1205
1476
  asks for the next version, writes it to both packages, and commits and tags
1206
- it (`v0.1.2`). Both are published (each builds itself first: `dist/` for the
1207
- core, one bundled file for the CLI), and only then is the commit pushed. The
1208
- settings are in `bump.config.ts`.
1477
+ it (`v0.1.2`), locally. Both are published (each builds itself first: `dist/`
1478
+ for the core, one bundled file for the CLI), and only then are the commit and
1479
+ tag pushed.
1480
+
1481
+ If a release stops partway — npm login, a one-time password, the network —
1482
+ fix it and run `npm run release` again. It sees the version isn't fully on npm
1483
+ yet and publishes what's missing, instead of bumping again. `npm run release
1484
+ -- --dry-run` does everything but publish and push. The script is
1485
+ `scripts/release.mjs`; bumpp's settings are in `bump.config.ts`.
1209
1486
 
1210
1487
  ## Tests
1211
1488
 
@@ -1218,8 +1495,8 @@ for the same private scalars, and pinned to recorded DIDs so an accidental
1218
1495
  change cannot slip through; recovery codes; account vaults, wraps and account
1219
1496
  stores; data folders with several writers; spaces, invites and
1220
1497
  encrypt-then-sign; UCAN issuing, attenuation and chain validation; phone
1221
- pairing; peer introductions; the MST; the validation gates; and two peers
1222
- reconciling over the anti-entropy protocol, including the forged, stolen,
1498
+ pairing; peer introductions; Negentropy, checked against the reference
1499
+ implementation; the validation gates; and two peers reconciling, including the forged, stolen,
1223
1500
  unauthorized and malformed expressions their gatekeepers reject. Above those:
1224
1501
  the node API — versioned records, links, queries, collection definitions,
1225
1502
  profiles, the account registry, moving and merging accounts — and the CLI.