@weaveprotocol/core 0.1.1 → 0.2.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.
Files changed (183) hide show
  1. package/README.md +264 -42
  2. package/dist/identity/contact-key.d.ts +42 -0
  3. package/dist/identity/contact-key.d.ts.map +1 -0
  4. package/dist/identity/contact-key.js +133 -0
  5. package/dist/identity/contact-key.js.map +1 -0
  6. package/dist/index.d.ts +16 -7
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +10 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/network/mesh.d.ts +6 -0
  11. package/dist/network/mesh.d.ts.map +1 -1
  12. package/dist/network/mesh.js +11 -1
  13. package/dist/network/mesh.js.map +1 -1
  14. package/dist/network/multi-signaling.d.ts +12 -2
  15. package/dist/network/multi-signaling.d.ts.map +1 -1
  16. package/dist/network/multi-signaling.js +86 -14
  17. package/dist/network/multi-signaling.js.map +1 -1
  18. package/dist/network/peer-auth.d.ts +50 -16
  19. package/dist/network/peer-auth.d.ts.map +1 -1
  20. package/dist/network/peer-auth.js +52 -15
  21. package/dist/network/peer-auth.js.map +1 -1
  22. package/dist/node/carrier.d.ts +87 -0
  23. package/dist/node/carrier.d.ts.map +1 -1
  24. package/dist/node/carrier.js +180 -59
  25. package/dist/node/carrier.js.map +1 -1
  26. package/dist/node/copy.d.ts.map +1 -1
  27. package/dist/node/copy.js +6 -3
  28. package/dist/node/copy.js.map +1 -1
  29. package/dist/node/host.d.ts +117 -0
  30. package/dist/node/host.d.ts.map +1 -0
  31. package/dist/node/host.js +192 -0
  32. package/dist/node/host.js.map +1 -0
  33. package/dist/node/index.d.ts +6 -2
  34. package/dist/node/index.d.ts.map +1 -1
  35. package/dist/node/index.js +1 -0
  36. package/dist/node/index.js.map +1 -1
  37. package/dist/node/node.d.ts.map +1 -1
  38. package/dist/node/node.js +640 -25
  39. package/dist/node/node.js.map +1 -1
  40. package/dist/node/space-runtime.d.ts +42 -25
  41. package/dist/node/space-runtime.d.ts.map +1 -1
  42. package/dist/node/space-runtime.js +734 -35
  43. package/dist/node/space-runtime.js.map +1 -1
  44. package/dist/node/types.d.ts +269 -3
  45. package/dist/node/types.d.ts.map +1 -1
  46. package/dist/privacy/space-encryption.d.ts +16 -0
  47. package/dist/privacy/space-encryption.d.ts.map +1 -1
  48. package/dist/privacy/space-encryption.js +40 -0
  49. package/dist/privacy/space-encryption.js.map +1 -1
  50. package/dist/query/engine.js +1 -1
  51. package/dist/query/engine.js.map +1 -1
  52. package/dist/query/types.d.ts +7 -0
  53. package/dist/query/types.d.ts.map +1 -1
  54. package/dist/query/types.js.map +1 -1
  55. package/dist/react/use-query.d.ts +1 -1
  56. package/dist/react/use-query.d.ts.map +1 -1
  57. package/dist/records/topics.d.ts +21 -0
  58. package/dist/records/topics.d.ts.map +1 -0
  59. package/dist/records/topics.js +86 -0
  60. package/dist/records/topics.js.map +1 -0
  61. package/dist/schema/collection-def.d.ts +7 -0
  62. package/dist/schema/collection-def.d.ts.map +1 -1
  63. package/dist/schema/collection-def.js +4 -0
  64. package/dist/schema/collection-def.js.map +1 -1
  65. package/dist/schema/expression.d.ts +2 -0
  66. package/dist/schema/expression.d.ts.map +1 -1
  67. package/dist/schema/expression.js +1 -0
  68. package/dist/schema/expression.js.map +1 -1
  69. package/dist/schemas/contacts.d.ts +77 -0
  70. package/dist/schemas/contacts.d.ts.map +1 -0
  71. package/dist/schemas/contacts.js +35 -0
  72. package/dist/schemas/contacts.js.map +1 -0
  73. package/dist/schemas/index.d.ts +2 -0
  74. package/dist/schemas/index.d.ts.map +1 -1
  75. package/dist/schemas/index.js +1 -0
  76. package/dist/schemas/index.js.map +1 -1
  77. package/dist/session/auth.d.ts.map +1 -1
  78. package/dist/session/auth.js +14 -3
  79. package/dist/session/auth.js.map +1 -1
  80. package/dist/session/connect.d.ts +25 -1
  81. package/dist/session/connect.d.ts.map +1 -1
  82. package/dist/session/connect.js +9 -2
  83. package/dist/session/connect.js.map +1 -1
  84. package/dist/session/hosting.d.ts +155 -0
  85. package/dist/session/hosting.d.ts.map +1 -0
  86. package/dist/session/hosting.js +160 -0
  87. package/dist/session/hosting.js.map +1 -0
  88. package/dist/space/account-registry.d.ts +6 -0
  89. package/dist/space/account-registry.d.ts.map +1 -1
  90. package/dist/space/account-registry.js +16 -4
  91. package/dist/space/account-registry.js.map +1 -1
  92. package/dist/space/notify.d.ts +88 -0
  93. package/dist/space/notify.d.ts.map +1 -0
  94. package/dist/space/notify.js +127 -0
  95. package/dist/space/notify.js.map +1 -0
  96. package/dist/space/pass.d.ts +6 -0
  97. package/dist/space/pass.d.ts.map +1 -1
  98. package/dist/space/pass.js +5 -2
  99. package/dist/space/pass.js.map +1 -1
  100. package/dist/space/roles.d.ts +71 -0
  101. package/dist/space/roles.d.ts.map +1 -1
  102. package/dist/space/roles.js +114 -0
  103. package/dist/space/roles.js.map +1 -1
  104. package/dist/space/space-access.d.ts +24 -0
  105. package/dist/space/space-access.d.ts.map +1 -1
  106. package/dist/space/space-access.js +24 -0
  107. package/dist/space/space-access.js.map +1 -1
  108. package/dist/space/space-manager.d.ts +35 -1
  109. package/dist/space/space-manager.d.ts.map +1 -1
  110. package/dist/space/space-manager.js +72 -25
  111. package/dist/space/space-manager.js.map +1 -1
  112. package/dist/storage/blob/memory.d.ts +9 -0
  113. package/dist/storage/blob/memory.d.ts.map +1 -0
  114. package/dist/storage/blob/memory.js +20 -0
  115. package/dist/storage/blob/memory.js.map +1 -0
  116. package/dist/storage/blob/s3.d.ts +16 -0
  117. package/dist/storage/blob/s3.d.ts.map +1 -0
  118. package/dist/storage/blob/s3.js +86 -0
  119. package/dist/storage/blob/s3.js.map +1 -0
  120. package/dist/storage/blob-store.d.ts +26 -0
  121. package/dist/storage/blob-store.d.ts.map +1 -0
  122. package/dist/storage/blob-store.js +2 -0
  123. package/dist/storage/blob-store.js.map +1 -0
  124. package/dist/storage/encrypted-adapter.d.ts +3 -3
  125. package/dist/storage/encrypted-adapter.js +3 -3
  126. package/dist/storage/folder-adapter.d.ts +8 -8
  127. package/dist/storage/folder-adapter.d.ts.map +1 -1
  128. package/dist/storage/folder-adapter.js +7 -7
  129. package/dist/storage/folder-reconcile.d.ts +12 -14
  130. package/dist/storage/folder-reconcile.d.ts.map +1 -1
  131. package/dist/storage/folder-reconcile.js +20 -14
  132. package/dist/storage/folder-reconcile.js.map +1 -1
  133. package/dist/storage/index.d.ts +0 -1
  134. package/dist/storage/index.d.ts.map +1 -1
  135. package/dist/storage/index.js +0 -1
  136. package/dist/storage/index.js.map +1 -1
  137. package/dist/storage/indexeddb-adapter.d.ts.map +1 -1
  138. package/dist/storage/indexeddb-adapter.js +8 -9
  139. package/dist/storage/indexeddb-adapter.js.map +1 -1
  140. package/dist/storage/mirror.d.ts +68 -0
  141. package/dist/storage/mirror.d.ts.map +1 -0
  142. package/dist/storage/mirror.js +174 -0
  143. package/dist/storage/mirror.js.map +1 -0
  144. package/dist/storage/segment.d.ts +20 -0
  145. package/dist/storage/segment.d.ts.map +1 -0
  146. package/dist/storage/segment.js +27 -0
  147. package/dist/storage/segment.js.map +1 -0
  148. package/dist/storage/storage-provider.d.ts +36 -40
  149. package/dist/storage/storage-provider.d.ts.map +1 -1
  150. package/dist/storage/storage-provider.js +208 -117
  151. package/dist/storage/storage-provider.js.map +1 -1
  152. package/dist/sync/index.d.ts +1 -1
  153. package/dist/sync/index.d.ts.map +1 -1
  154. package/dist/sync/index.js +1 -1
  155. package/dist/sync/index.js.map +1 -1
  156. package/dist/sync/negentropy.d.ts +68 -0
  157. package/dist/sync/negentropy.d.ts.map +1 -0
  158. package/dist/sync/negentropy.js +379 -0
  159. package/dist/sync/negentropy.js.map +1 -0
  160. package/dist/sync/sync-engine.d.ts +45 -13
  161. package/dist/sync/sync-engine.d.ts.map +1 -1
  162. package/dist/sync/sync-engine.js +290 -207
  163. package/dist/sync/sync-engine.js.map +1 -1
  164. package/dist/sync/sync-messages.d.ts +42 -32
  165. package/dist/sync/sync-messages.d.ts.map +1 -1
  166. package/dist/sync/sync-messages.js +1 -1
  167. package/dist/sync/sync-messages.js.map +1 -1
  168. package/dist/types.d.ts +9 -0
  169. package/dist/types.d.ts.map +1 -1
  170. package/dist/types.js.map +1 -1
  171. package/dist/utils/hash.d.ts +9 -0
  172. package/dist/utils/hash.d.ts.map +1 -1
  173. package/dist/utils/hash.js +33 -0
  174. package/dist/utils/hash.js.map +1 -1
  175. package/package.json +9 -3
  176. package/dist/storage/mst.d.ts +0 -121
  177. package/dist/storage/mst.d.ts.map +0 -1
  178. package/dist/storage/mst.js +0 -402
  179. package/dist/storage/mst.js.map +0 -1
  180. package/dist/sync/anti-entropy.d.ts +0 -49
  181. package/dist/sync/anti-entropy.d.ts.map +0 -1
  182. package/dist/sync/anti-entropy.js +0 -62
  183. package/dist/sync/anti-entropy.js.map +0 -1
package/README.md CHANGED
@@ -11,11 +11,11 @@ devices, and every app is a view onto that data rather than its owner.
11
11
  │ Applications │
12
12
  ├──────────────┬───────────┬────────────┬──────────┬───────────┤
13
13
  │ 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 │
14
+ │ seed, vault, │ roles, │ crypto → │ AES-GCM │ Negentropy│
15
+ │ root signer, │ members × │ structural │ per │ per │
16
+ │ UCAN, pairing│ pub/priv │ → UCAN │ space │ collection│
17
17
  ├──────────────┴───────────┴────────────┴──────────┴───────────┤
18
- │ Storage: Merkle Search Tree over a StorageAdapter │
18
+ │ Storage: signed versions + plain index over an adapter │
19
19
  │ IndexedDB (per origin) · data folder (shared by origins) │
20
20
  ├──────────────────────────────────────────────────────────────┤
21
21
  │ Network: WebRTC data channels │
@@ -71,7 +71,7 @@ const ucan = await root.delegate({
71
71
  const spaces = createSpaceManager(await createIndexedDBAdapter('my-app/registry'));
72
72
  const { space } = await spaces.create({ name: 'Notes', visibility: 'public', creator: me.did });
73
73
 
74
- // 4. A signed record, stored in that space's own Merkle tree
74
+ // 4. A signed record, stored in that space's own store
75
75
  const storage = createStorageProvider(await createIndexedDBAdapter(`my-app/space/${space.id}`));
76
76
  const signed = await createSigner(provider).sign(
77
77
  createExpression({
@@ -222,6 +222,61 @@ await calls.answer(ringing.id); calls.setMuted(true); await calls.shareScreen(
222
222
 
223
223
  The messages, and the reasoning, are at the top of `src/calls/calls.ts`.
224
224
 
225
+ ### Contacts
226
+
227
+ A DID is a **name, not an address**: it's on everything you sign, so knowing
228
+ it must not be enough to reach you. What lets two people reach each other is a
229
+ space they share. So **a contact is a private space for two**: records you
230
+ write there wait for the other person, and live messages reach them when
231
+ they're online. There's no directory to look people up in, and no inbox
232
+ strangers can knock on.
233
+
234
+ ```typescript
235
+ // In a space you share with Anna — the book club — ask her to add you.
236
+ const { space } = await node.contacts.ask(club.id, anna, { note: "it's Leif from book club" });
237
+
238
+ // On Anna's side:
239
+ const [request] = await node.contacts.requests(club.id); // { from, name, note, pairSpace, … }
240
+ await node.contacts.accept(club.id, request.key); // joins the space for two, adds Leif
241
+
242
+ await node.contacts.list(); // [{ did, name, space, note, blocked }]
243
+ await node.contacts.put({ did, name: 'Anna K' }); // what you call them — only you see it
244
+ await node.contacts.remove(anna); // off the list, and out of your space with her
245
+ await node.contacts.block(anna); // and her requests are hidden in every space
246
+ await node.contacts.others(anna); // anyone else in your space with her: [] unless someone was let in
247
+ ```
248
+
249
+ - **The list** is one `std.contact` per person, in a **contacts space**
250
+ derived from the account key the way the account registry is: every device
251
+ of the account has it, nobody else can find it, and it's hidden from
252
+ `spaces.list`. `contacts.space()` gives its id. A carrier carries it, so a
253
+ restore brings the list back.
254
+ - **Asking** (`ask`) makes a private space for the two of you, puts them on
255
+ your list with it, and posts a `std.contact-request` in the shared space:
256
+ the new space's invite, **sealed with their contact key**. The other members
257
+ can see that you asked, not what. The seal is bound to the space it was
258
+ posted in and to who asked whom, so a request copied into another space, or
259
+ re-posted by someone else, doesn't open. Joining is the answer: there's no
260
+ reply record.
261
+ - **The contact key** is a P-256 key for encryption, derived from the seed
262
+ under its own label (`deriveContactKeyBytes`), so it's the same on every
263
+ device and comes back with the recovery code. Its public half goes on your
264
+ profile in every space you write in (`spaces.profiles(id)[n].contactKey`),
265
+ and only counts on a profile signed by your own account. An app without the
266
+ key never takes it off: a profile keeps the newest key it was given.
267
+ - **Conversation or group** is decided by how the space was made, not by how
268
+ many people are in it: a space is your conversation with Anna because your
269
+ list says so. A space made with "New space" and shared with one person is
270
+ never one. If a third account turns up in a space for two, `others` says so,
271
+ and an app can offer to start a new space with just the two of you, or a new
272
+ group with everyone.
273
+
274
+ **What an app gets.** A home gives the contacts to an app that asks with
275
+ `contacts: true`: the contacts space as one more space in its grant, and the
276
+ contact key, which opens requests sent to you. Asking someone and accepting
277
+ also make or join a space, so those need `scope: 'account'`, which includes
278
+ the contacts. Agents never get them.
279
+
225
280
  ## Signing in — the element, and React
226
281
 
227
282
  Getting to a node takes a sign-in flow: where the data lives (a pod or this
@@ -362,12 +417,51 @@ the account's list on every device.
362
417
  An app that is a view onto *everything* — like the example — asks for
363
418
  `scope: 'account'`: a note for every space, plus the key the account's space
364
419
  list is derived from, so it sees every space and can make and join them. It
420
+ gets the contacts too (see Contacts, above). It
365
421
  still never holds the seed: it cannot sign in anywhere as the account, change
366
422
  its password or passkeys, or keep access past the note's date.
367
423
 
368
424
  The home side is `receiveConnectRequest()` and `auth.grant(…)`; see
369
425
  [home/README.md](home/README.md).
370
426
 
427
+ ### Apps hold what they use — keepers hold the rest
428
+
429
+ A space can name its **keepers**: nodes that hold every record of it, like a
430
+ host or the extension (`node.spaces.setKeepers`, for someone who manages the
431
+ space; the account's carriers are named by themselves in every space it
432
+ manages). Once it does, an app connected to the account home holds only the
433
+ collections it uses, besides the space's own (`sys.*`):
434
+
435
+ - a query says what it needs — its collection and every `include … from` —
436
+ and those start syncing from any peer that has them;
437
+ - `result.complete` is false until they have caught up with a node holding
438
+ the whole space: show "Loading…", not "Nothing here";
439
+ - the app's own writes stay **pending** until enough keepers say they have
440
+ them (`stored`): the space's `copies`, or 2, never more than it names.
441
+ Nothing pending is ever dropped;
442
+ - a collection no query has touched for `unusedAfterDays` (30) is dropped,
443
+ unless the app declared it (`cache.collections`). It syncs back when needed.
444
+
445
+ A collection can also name **topic fields** (`topics: ['channel', 'mentions']`).
446
+ Each record then carries, on its outside, a keyed hash of each of those values
447
+ (`tags`), so a keeper that can't read the record can still match "in
448
+ #design" or "mentions me" — learning which records share a topic, never which
449
+ topic. `node.collections.tag(space, collection, field, value)` gives the tag to
450
+ match; anyone who can read a record checks its tags and refuses a mismatch.
451
+
452
+ **"Notify me when…"** (`node.notifications`, and the home's section of that
453
+ name): new records in a collection, in every space or some, perhaps only with
454
+ a topic value ("mentions me"), perhaps only other people's. The account
455
+ registry keeps each one sealed; every carrier gets it with the value replaced
456
+ by each space's tag, so the Weave extension notices matching records as they
457
+ arrive and shows a notification — the space, your label, the time — without
458
+ being able to read them, or what you asked for.
459
+
460
+ A space that names no keeper is held whole, as before: then the app may be one
461
+ of its copies. How much to hold is each node's choice (`NodeConfig.cache`;
462
+ `startConnectedNode` turns it on unless given `cache: false`); only how
463
+ keepers confirm is protocol. `spaces.status(id)` shows `holds` and `pending`.
464
+
371
465
  ## Modules
372
466
 
373
467
  ### Identity (`@weaveprotocol/core/identity`)
@@ -484,16 +578,27 @@ Typed, signed data expressions using [Standard Schema](https://standardschema.de
484
578
 
485
579
  ### Storage (`@weaveprotocol/core/storage`)
486
580
 
487
- Local-first storage with Merkle Search Tree for efficient sync.
581
+ Local-first storage: signed versions, and plain entries saying which is current
582
+ and which versions each collection keeps — the set sync compares.
488
583
 
489
584
  | Export | Description |
490
585
  |--------|-------------|
491
- | `createStorageProvider()` | MST-backed expression storage; `compact()` deletes tree nodes the root no longer reaches |
586
+ | `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
587
  | `createIndexedDBAdapter()` | IndexedDB storage adapter, scoped to this origin |
493
588
  | `createFolderAdapter()` | A user-picked directory, shared by every origin given access |
494
589
  | `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 |
590
+ | `reconcileFolder()` | Places the files another writer added to a folder, and drops entries whose file is gone |
591
+ | `createMirror()` | Keeps a space in a dumb file store too, synced like a peer that never runs code |
592
+ | `createS3BlobStore()` / `createMemoryBlobStore()` | File stores a mirror can use: any S3-compatible bucket (R2, B2, MinIO, AWS), or memory |
593
+
594
+ **Mirrors.** A bucket or an app folder can hold a space: each writer (one store
595
+ on one device, with a random id) only ever adds immutable segments in its own
596
+ folder, named by a counter and the hash of their bytes — so nothing is written
597
+ twice and nothing needs a lock. A segment holds versions exactly as they travel,
598
+ private bodies still sealed, and everything read back passes the same gates as a
599
+ peer's records: the store can hide things, not forge them. What a writer knows
600
+ the store holds, it never uploads again; a writer compacts its own segments
601
+ into fewer. Merging is the protocol's own — a set of versions that only grows.
497
602
 
498
603
  ### Spaces
499
604
 
@@ -523,7 +628,7 @@ const { space, key } = await spaces.create({
523
628
  });
524
629
  ```
525
630
 
526
- Give each space its own storage and its own MST and a peer you share one list
631
+ Give each space its own storage and its own sync and a peer you share one list
527
632
  with learns nothing about the others.
528
633
 
529
634
  #### Who may write: roles, and a history every peer replays
@@ -543,8 +648,9 @@ and roles ranked below you, and give out roles up to your own rank. Two people
543
648
  at the same rank can never remove each other, only themselves — so the creator
544
649
  **hands over** by giving someone their role, then leaving, and the space goes on.
545
650
 
546
- Roles, members, invites, revoked notes and collection definitions are records
547
- (`sys.role`, `sys.member`, `sys.invite`, `sys.revoke`, `sys.collection`), and
651
+ Roles, members, invites, revoked notes, collection definitions and changes of a
652
+ private space's key are records (`sys.role`, `sys.member`, `sys.invite`,
653
+ `sys.revoke`, `sys.collection`, `sys.key`), and
548
654
  every record written anywhere names the latest of them its writer knew, as
549
655
  `seen`. That makes the **access history** a small graph, which every peer
550
656
  replays the same way (`space/roles.ts`): a change comes after what it saw;
@@ -586,15 +692,35 @@ connect under someone else's name, and in a private space with the read key
586
692
  too, checked against its public half. A stranger who learns a space's id, or a
587
693
  relay that sees its room, gets no ciphertext. A peer-to-peer handshake also
588
694
  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).
695
+ sit in the middle is caught.
696
+
697
+ **Removing someone from a private space changes its key**, the way Keybase
698
+ changes a team's key. Whoever manages the space, seeing someone lose their
699
+ place (removed, left, or their role deleted), makes a new key by itself and
700
+ writes a `sys.key` record: the new key's id and public read key, and every
701
+ earlier key sealed under the new one. It is part of the access history, so two
702
+ new keys made apart resolve like any other change, and only `manage` may make
703
+ one. Each member's copy goes in a `sys.box`, sealed to their **member key**: a
704
+ key pair per account per space, derived from the account's vault key, whose
705
+ public half each member publishes in the clear (`sys.memberkey`). An account
706
+ home hands an app the member keys of exactly the spaces it grants.
707
+
708
+ From then on new records are sealed with the new key. Someone removed keeps what
709
+ they could already read, and nothing after it. Holding the current key opens
710
+ every earlier one, so a newcomer reads the space's past. A member who was away
711
+ when the key changed still holds only the older read key: they prove that, and
712
+ send their note sealed under the older key, and a peer holding it lets them in
713
+ if they are still a member. A host with no key takes the current read key only.
714
+ View-only links made before the change stop working; share a new one.
715
+ `node.spaces.changeKey(space)` does the same by hand, for a lost device.
591
716
 
592
717
  A space's **id is the hash of what is fixed at creation**: creator, visibility,
593
718
  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.
719
+ first read key (the name is left out, so it can change). `join` refuses an
720
+ invite whose space does not hash to its id — so whoever passes an invite on
721
+ cannot change who started the space, or with which roles. The key an invite
722
+ carries may be a later one; its id is its hash, so it is either the key the
723
+ history names or one that opens nothing.
598
724
 
599
725
  **Spaces describe themselves.** A space stores its collections' definitions —
600
726
  name, title, description and a JSON Schema — as signed records in
@@ -739,8 +865,10 @@ Browser-to-browser communication via WebRTC.
739
865
 
740
866
  #### Signaling relay
741
867
 
742
- `server/signaling-server.mjs` is a dumb relay in a couple hundred lines of
743
- Node on the `ws` library: a peer holds one socket and joins a room on it for
868
+ `server/relay.mjs` is a dumb relay in a couple hundred lines of Node with no
869
+ dependencies of its own. It runs on its own as `server/signaling-server.mjs`
870
+ (an HTTP server and the `ws` library around it), and inside every always-on
871
+ node (`weave serve`), so the two are the same relay. A peer holds one socket and joins a room on it for
744
872
  each space, and the relay passes join notices and WebRTC offers, answers and
745
873
  candidates between peers that share a room. (A socket opened with `?room=` is
746
874
  the older one-room form, still served.) The room is a hash of
@@ -772,23 +900,36 @@ tell a Weave peer from anyone else, so set coturn's own quotas (`user-quota`,
772
900
  `total-quota`, `max-bps`). TURN only forwards encrypted packets; it can't
773
901
  see or hear a call.
774
902
 
903
+ The relay's Docker image (`server/`, deployed to Fly) runs coturn beside it when
904
+ `TURN_SECRET` is set, already capped that way: `server/fly.toml` says how.
905
+
775
906
  Only peers already in a room hear about a newcomer, so exactly one side creates
776
907
  the offer and the two never collide.
777
908
 
778
909
  ### Sync (`@weaveprotocol/core/sync`)
779
910
 
780
- Anti-entropy gossip protocol for eventual consistency.
911
+ Range-based set reconciliation (Negentropy, as Nostr's NIP-77), one collection at a time.
781
912
 
782
913
  | Export | Description |
783
914
  |--------|-------------|
784
- | `createSyncEngine()` | Automatic MST reconciliation with heartbeat |
785
- | `verifyNode()` / `unknownChildren()` | The pieces of a tree walk |
786
-
787
- Two peers compare roots — equal means identical, one round trip. Otherwise each
788
- walks the other's tree from the root, skipping every subtree already in its own,
789
- so cost follows the size of the difference: one changed entry in 10,000 costs
790
- about 37 KB on the wire, where sending every key cost 508 KB. Whether a subtree
791
- is already here is one lookup in the store, not a read of the whole local tree.
915
+ | `createSyncEngine()` | Reconciles a space with its peers, with a heartbeat |
916
+ | `createReconciler()` / `ItemSet` / `fingerprintOf()` | Negentropy itself, wire-compatible with the reference implementation |
917
+
918
+ A peer says hello with a fingerprint of each collection it keeps — in essence
919
+ the sum of its version ids. Equal fingerprints mean the same versions, and that
920
+ collection is done. For each one that differs, the peer whose id sorts first
921
+ compares fingerprints of ever smaller ranges with the other until both know
922
+ exactly which versions each lacks; then it asks for what it lacks and sends what
923
+ the other does. Cost follows the difference, not the size: one new version
924
+ among 2,000 syncs in under 8 KB, all told. Nothing is stored for sync beyond
925
+ the versions' own index entries: the sets and sums live in memory, updated by
926
+ every change and read again when another writer (a tab, a folder) changed the
927
+ store.
928
+
929
+ Each hello also says what the peer holds — `all`, or some collections — and
930
+ two peers reconcile only the collections both hold. A version for a collection
931
+ a node doesn't hold is passed over; one it takes in, it tells the sender it has
932
+ (`stored`). One peer's messages are handled in the order they came.
792
933
 
793
934
  The engine's `validate` hook is the seam where the validation engine sits.
794
935
  Expressions a peer sends are only committed if it accepts them; the rest are
@@ -875,7 +1016,7 @@ A directory handle is the exception. Each origin asks for permission once, and b
875
1016
  accounts.json name, DID and id of each account (readable without unlocking)
876
1017
  accounts/<id>/account.json that account's seed, encrypted once per way of unlocking it
877
1018
  accounts/<id>/stores/<namespace>/
878
- kv/<key> MST nodes, the root pointer, space records (sealed)
1019
+ kv/<key> index entries (current, first, retained, by collection), space records (sealed)
879
1020
  expressions/<cid>.json one signed record per file
880
1021
  ```
881
1022
 
@@ -908,11 +1049,11 @@ const registry = createEncryptedAdapter(
908
1049
  );
909
1050
  ```
910
1051
 
911
- **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.
1052
+ **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.
912
1053
 
913
1054
  Two consequences worth having:
914
1055
 
915
- - **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.
1056
+ - **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.
916
1057
  - **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.
917
1058
 
918
1059
  ### Locking the folder
@@ -937,7 +1078,7 @@ await accounts.write(account, withWrap(vault, wrap));
937
1078
 
938
1079
  `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.
939
1080
 
940
- `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.
1081
+ `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.
941
1082
 
942
1083
  ### Where the root key lives
943
1084
 
@@ -989,6 +1130,18 @@ space with is a peer in that one only.
989
1130
  **Several relays**, so there is no single phone book — a peer announced by two
990
1131
  of them is announced upward once, and replies go back the way they arrived.
991
1132
 
1133
+ **The space says where it meets**, like a Nostr relay list, but the space's
1134
+ own. `sys.relays` is part of the access history, so only someone who manages
1135
+ the space changes it (`node.spaces.setRelays`), and a new space names its
1136
+ creator's relays by itself. Invites carry the list, so a joiner whose app uses
1137
+ other relays reaches the space before anything has synced. Every member then
1138
+ joins the space's room on those relays as well as their own
1139
+ (`mesh.useRelays(room, relays)`); relays only one space names are joined for
1140
+ that space's room alone, and dropped once no open space names them. Moving a
1141
+ space to another relay, a self-hosted one say, is one change, and everyone
1142
+ follows. No DHT: browsers can't be DHT nodes, and would need a relay to reach
1143
+ one anyway.
1144
+
992
1145
  **Peers introduce peers**, so a relay is only needed for the *first* connection.
993
1146
  Once you are connected to someone, their data channel carries signalling for the
994
1147
  peers you have not met: `__peers` says who I can see, `__signal` carries
@@ -1087,6 +1240,27 @@ it doubles as a relay), and `weave mcp` to hand the same operations to an agent.
1087
1240
  It reads and writes the same data folder layout a browser does. See
1088
1241
  [cli/README.md](cli/README.md).
1089
1242
 
1243
+ **Hosting.** `weave host` keeps many accounts' spaces online without being able
1244
+ to read them: it is the extension's carrier (`createCarrierNode`) with one carry
1245
+ space per paying account (`createHostNode`), each space held once however many
1246
+ members pay for it. A subscription is a key the account makes and keeps in its
1247
+ registry (`sys.hosting`), so every device signs as it; `node.hosting.use(url)`
1248
+ starts one, and whichever device notices it is paid hands the host the carry
1249
+ space. Every call to the host is signed over method, path, time and body.
1250
+ The home knows nothing about payment (BLOCK-23): a host describes itself at
1251
+ `/.well-known/weave-host` (like a Nostr relay's NIP-11 document), signs every
1252
+ status it gives — the home keeps the latest in the registry as the person's
1253
+ proof — and takes payments on its own pay page, which `node.hosting.payPage(url)`
1254
+ links to with a signature by the subscription key, valid for an hour at that
1255
+ host only. On the page: Stripe Checkout and the Customer Portal (card, Apple
1256
+ Pay, Google Pay), whose webhook moves a paid-until date taken from Stripe's own
1257
+ billing period; and USDC from a crypto wallet straight to the host's address,
1258
+ checked on the network by the host. Past the date: a grace period, then the host drops
1259
+ the spaces and deletes its copy. With a bucket (`WEAVE_S3_*`) the host's disk
1260
+ is only a cache — every carried space and the subscription list live in the
1261
+ bucket, sealed, and a new machine starts from it. The account home's Settings
1262
+ has **Keep my spaces online**.
1263
+
1090
1264
  ## Example app
1091
1265
 
1092
1266
  `example/` is the Weave website — a landing page for developers at `/`, and
@@ -1095,15 +1269,48 @@ the protocol straight from `src/`. It knows no kinds of data in advance: every
1095
1269
  screen is worked out from what a space says about itself (see *Derived UI* below):
1096
1270
 
1097
1271
  ```bash
1098
- npm install && (cd example && npm install) && (cd home && npm install) && (cd cli && npm install)
1272
+ npm install && (cd example && npm install) && (cd home && npm install) # the CLI is a workspace: the first one installs it
1099
1273
  npm run dev
1100
1274
  ```
1101
1275
 
1102
- That starts three things: the example app on 5173, the account home it
1103
- connects to on 5174, and an always-on node on port 8787 that is also the relay.
1104
- The node gets a throwaway identity on first run (`cli/.env.dev`, data in
1105
- `.weave-dev/`), and `example/.env.development` points the app at the other two.
1106
- Override either in a `.env.local`.
1276
+ That starts everything, with coloured output per part, and Ctrl-C stops it all:
1277
+
1278
+ | Part | Where | What |
1279
+ |---|---|---|
1280
+ | app | http://localhost:5173 | The example app |
1281
+ | home | http://localhost:5174 | The account home it connects to |
1282
+ | 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/`) |
1283
+ | host | http://localhost:8788 | `weave host`, what "Keep my spaces online" uses, with its pay page at `/pay`; settings in `cli/.env.host.dev` |
1284
+ | stripe | — | Only when you add a Stripe test key (below): forwards Stripe's webhooks to the host |
1285
+
1286
+ `example/.env.development` and `home/.env.development` point the app and home
1287
+ at the rest. Override any of them in a `.env.local`, and the host in
1288
+ `cli/.env.host.local`.
1289
+
1290
+ **Trying hosting and payments.** In the home: Settings, **Keep my spaces
1291
+ online**, **Keep online** (the dev host is filled in), then **Payment**, which
1292
+ opens the host's pay page in a new tab. What you can pay with there:
1293
+
1294
+ - **A browser wallet, on by default.** Payments go to Base Sepolia, a test
1295
+ network: test money only. In MetaMask (or any browser wallet), get test ETH
1296
+ for the fee from a Base Sepolia faucet (Coinbase's, or Alchemy's) and test
1297
+ USDC from faucet.circle.com (choose Base Sepolia). Pay, and the pay page
1298
+ says "Payment received"; back in the home's tab, the host shows "paid until"
1299
+ and takes your spaces. To see payments arrive, set your own address as
1300
+ `WEAVE_WALLET_ADDRESS` in `cli/.env.host.local`.
1301
+ - **A card, in Stripe's test mode.** In `cli/.env.host.local`, add
1302
+ `STRIPE_SECRET_KEY=sk_test_…` and the price ids of a monthly and a yearly
1303
+ recurring test price (`STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, from the Stripe
1304
+ dashboard in test mode). With the Stripe CLI installed, `npm run dev`
1305
+ forwards Stripe's webhooks to the host by itself. Pay with card
1306
+ 4242 4242 4242 4242, any future date, any CVC.
1307
+ - **Phone wallets, by QR code.** Add `WEAVE_WALLETCONNECT_PROJECT_ID` (free at
1308
+ dashboard.reown.com, with localhost allowed in the project) to
1309
+ `cli/.env.host.local`; `npm run dev` builds what the pay page needs. The
1310
+ wallet has to be on Base Sepolia too.
1311
+
1312
+ `npm test` covers the same paths without any of this: a fake network, fake
1313
+ Stripe calls, and the home's side against a real host.
1107
1314
 
1108
1315
  The example never signs anyone in: "Connect with Weave" opens the home, where
1109
1316
  you make an account or sign in, and allow the example your whole account. Its
@@ -1127,7 +1334,7 @@ between pods, pair a phone by QR code, and see which apps you connected. In the
1127
1334
  example: make private or public spaces, just yours or with people you invite, and share one with a
1128
1335
  friend via an invite link. Everyone in a space is shown by the name
1129
1336
  they gave. Every record is signed by a delegated session key, stored in that
1130
- space's MST, encrypted first if the space is private, and gossiped to peers over
1337
+ space's own store, encrypted first if the space is private, and gossiped to peers over
1131
1338
  WebRTC; a record says *verified* once its signature and its delegation chain
1132
1339
  check out here, and *encrypted* when it arrived encrypted.
1133
1340
 
@@ -1190,6 +1397,21 @@ with every tab closed. What it writes shows "via agent", and every peer
1190
1397
  ignores an agent changing collections, who may do what, or the account's own
1191
1398
  list of spaces. See BLOCK-20.
1192
1399
 
1400
+ ## Releasing
1401
+
1402
+ `@weaveprotocol/core` (this folder) and `@weaveprotocol/cli` (`cli/`, an npm
1403
+ workspace) are released together, always with the same version:
1404
+
1405
+ ```bash
1406
+ npm run release
1407
+ ```
1408
+
1409
+ It typechecks and runs the tests, then [bumpp](https://github.com/antfu-collective/bumpp)
1410
+ asks for the next version, writes it to both packages, and commits and tags
1411
+ it (`v0.1.2`). Both are published (each builds itself first: `dist/` for the
1412
+ core, one bundled file for the CLI), and only then is the commit pushed. The
1413
+ settings are in `bump.config.ts`.
1414
+
1193
1415
  ## Tests
1194
1416
 
1195
1417
  ```bash
@@ -1201,8 +1423,8 @@ for the same private scalars, and pinned to recorded DIDs so an accidental
1201
1423
  change cannot slip through; recovery codes; account vaults, wraps and account
1202
1424
  stores; data folders with several writers; spaces, invites and
1203
1425
  encrypt-then-sign; UCAN issuing, attenuation and chain validation; phone
1204
- pairing; peer introductions; the MST; the validation gates; and two peers
1205
- reconciling over the anti-entropy protocol, including the forged, stolen,
1426
+ pairing; peer introductions; Negentropy, checked against the reference
1427
+ implementation; the validation gates; and two peers reconciling, including the forged, stolen,
1206
1428
  unauthorized and malformed expressions their gatekeepers reject. Above those:
1207
1429
  the node API — versioned records, links, queries, collection definitions,
1208
1430
  profiles, the account registry, moving and merging accounts — and the CLI.
@@ -0,0 +1,42 @@
1
+ export interface ContactKeyPair {
2
+ /** The public half: a compressed P-256 point, base64url. What goes on a profile. */
3
+ readonly publicKey: string;
4
+ /** The private half, for opening what was sealed to the public one. Not extractable. */
5
+ readonly privateKey: CryptoKey;
6
+ }
7
+ /**
8
+ * The contact key's private scalar, from the account seed — 32 bytes. This is
9
+ * what an account home hands an app it lets handle contacts.
10
+ */
11
+ export declare function deriveContactKeyBytes(seed: Uint8Array): Promise<Uint8Array>;
12
+ /**
13
+ * An account's **member key** for one space: the same kind of key pair, derived
14
+ * from the account's vault key and the space id. When a private space's key
15
+ * changes, the new key is sealed to each member's member key (`sys.box`).
16
+ *
17
+ * One per space, not one per account, so an account home can hand an app the
18
+ * member keys for exactly the spaces it grants — and a new space key reaches
19
+ * that app without it holding anything that opens other spaces' keys.
20
+ * @param accountKey The vault key bytes (`deriveVaultKeyBytes(seed)`)
21
+ */
22
+ export declare function deriveMemberKeyBytes(accountKey: Uint8Array, spaceId: string): Promise<Uint8Array>;
23
+ /** The public half, from the private scalar */
24
+ export declare function contactPublicKey(secret: Uint8Array): string;
25
+ /** Whether a string is a contact key's public half: a point on P-256 */
26
+ export declare function isContactPublicKey(value: unknown): value is string;
27
+ /** The key pair, from the private scalar (`deriveContactKeyBytes`) */
28
+ export declare function contactKeyPair(secret: Uint8Array): Promise<ContactKeyPair>;
29
+ /**
30
+ * Seals a value so only the holder of a contact key can open it.
31
+ * @param recipient The contact key's public half, as a profile carries it
32
+ * @param value Anything JSON can carry
33
+ * @param context What the sealed value belongs to; opening needs the same
34
+ * @returns base64url: the ephemeral public point, the IV, and the ciphertext
35
+ */
36
+ export declare function sealFor(recipient: string, value: unknown, context: string): Promise<string>;
37
+ /**
38
+ * Opens what `sealFor` sealed.
39
+ * @returns The value, or null when it was not sealed for this key, or for this context, or was changed
40
+ */
41
+ export declare function openSealed(privateKey: CryptoKey, sealed: string, context: string): Promise<unknown>;
42
+ //# sourceMappingURL=contact-key.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contact-key.d.ts","sourceRoot":"","sources":["../../src/identity/contact-key.ts"],"names":[],"mappings":"AAkCA,MAAM,WAAW,cAAc;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,wFAAwF;IACxF,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;CAChC;AAYD;;;GAGG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAEjF;AAED;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CAAC,UAAU,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAEvG;AAED,+CAA+C;AAC/C,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAE3D;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAQlE;AAED,sEAAsE;AACtE,wBAAsB,cAAc,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,cAAc,CAAC,CAiBhF;AASD;;;;;;GAMG;AACH,wBAAsB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAqBjG;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAAC,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA2BzG"}