@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.
- package/README.md +264 -42
- package/dist/identity/contact-key.d.ts +42 -0
- package/dist/identity/contact-key.d.ts.map +1 -0
- package/dist/identity/contact-key.js +133 -0
- package/dist/identity/contact-key.js.map +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/network/mesh.d.ts +6 -0
- package/dist/network/mesh.d.ts.map +1 -1
- package/dist/network/mesh.js +11 -1
- package/dist/network/mesh.js.map +1 -1
- package/dist/network/multi-signaling.d.ts +12 -2
- package/dist/network/multi-signaling.d.ts.map +1 -1
- package/dist/network/multi-signaling.js +86 -14
- package/dist/network/multi-signaling.js.map +1 -1
- package/dist/network/peer-auth.d.ts +50 -16
- package/dist/network/peer-auth.d.ts.map +1 -1
- package/dist/network/peer-auth.js +52 -15
- package/dist/network/peer-auth.js.map +1 -1
- package/dist/node/carrier.d.ts +87 -0
- package/dist/node/carrier.d.ts.map +1 -1
- package/dist/node/carrier.js +180 -59
- package/dist/node/carrier.js.map +1 -1
- package/dist/node/copy.d.ts.map +1 -1
- package/dist/node/copy.js +6 -3
- package/dist/node/copy.js.map +1 -1
- package/dist/node/host.d.ts +117 -0
- package/dist/node/host.d.ts.map +1 -0
- package/dist/node/host.js +192 -0
- package/dist/node/host.js.map +1 -0
- package/dist/node/index.d.ts +6 -2
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -0
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.d.ts.map +1 -1
- package/dist/node/node.js +640 -25
- package/dist/node/node.js.map +1 -1
- package/dist/node/space-runtime.d.ts +42 -25
- package/dist/node/space-runtime.d.ts.map +1 -1
- package/dist/node/space-runtime.js +734 -35
- package/dist/node/space-runtime.js.map +1 -1
- package/dist/node/types.d.ts +269 -3
- package/dist/node/types.d.ts.map +1 -1
- package/dist/privacy/space-encryption.d.ts +16 -0
- package/dist/privacy/space-encryption.d.ts.map +1 -1
- package/dist/privacy/space-encryption.js +40 -0
- package/dist/privacy/space-encryption.js.map +1 -1
- package/dist/query/engine.js +1 -1
- package/dist/query/engine.js.map +1 -1
- package/dist/query/types.d.ts +7 -0
- package/dist/query/types.d.ts.map +1 -1
- package/dist/query/types.js.map +1 -1
- package/dist/react/use-query.d.ts +1 -1
- package/dist/react/use-query.d.ts.map +1 -1
- package/dist/records/topics.d.ts +21 -0
- package/dist/records/topics.d.ts.map +1 -0
- package/dist/records/topics.js +86 -0
- package/dist/records/topics.js.map +1 -0
- package/dist/schema/collection-def.d.ts +7 -0
- package/dist/schema/collection-def.d.ts.map +1 -1
- package/dist/schema/collection-def.js +4 -0
- package/dist/schema/collection-def.js.map +1 -1
- package/dist/schema/expression.d.ts +2 -0
- package/dist/schema/expression.d.ts.map +1 -1
- package/dist/schema/expression.js +1 -0
- package/dist/schema/expression.js.map +1 -1
- package/dist/schemas/contacts.d.ts +77 -0
- package/dist/schemas/contacts.d.ts.map +1 -0
- package/dist/schemas/contacts.js +35 -0
- package/dist/schemas/contacts.js.map +1 -0
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/session/auth.d.ts.map +1 -1
- package/dist/session/auth.js +14 -3
- package/dist/session/auth.js.map +1 -1
- package/dist/session/connect.d.ts +25 -1
- package/dist/session/connect.d.ts.map +1 -1
- package/dist/session/connect.js +9 -2
- package/dist/session/connect.js.map +1 -1
- package/dist/session/hosting.d.ts +155 -0
- package/dist/session/hosting.d.ts.map +1 -0
- package/dist/session/hosting.js +160 -0
- package/dist/session/hosting.js.map +1 -0
- package/dist/space/account-registry.d.ts +6 -0
- package/dist/space/account-registry.d.ts.map +1 -1
- package/dist/space/account-registry.js +16 -4
- package/dist/space/account-registry.js.map +1 -1
- package/dist/space/notify.d.ts +88 -0
- package/dist/space/notify.d.ts.map +1 -0
- package/dist/space/notify.js +127 -0
- package/dist/space/notify.js.map +1 -0
- package/dist/space/pass.d.ts +6 -0
- package/dist/space/pass.d.ts.map +1 -1
- package/dist/space/pass.js +5 -2
- package/dist/space/pass.js.map +1 -1
- package/dist/space/roles.d.ts +71 -0
- package/dist/space/roles.d.ts.map +1 -1
- package/dist/space/roles.js +114 -0
- package/dist/space/roles.js.map +1 -1
- package/dist/space/space-access.d.ts +24 -0
- package/dist/space/space-access.d.ts.map +1 -1
- package/dist/space/space-access.js +24 -0
- package/dist/space/space-access.js.map +1 -1
- package/dist/space/space-manager.d.ts +35 -1
- package/dist/space/space-manager.d.ts.map +1 -1
- package/dist/space/space-manager.js +72 -25
- package/dist/space/space-manager.js.map +1 -1
- package/dist/storage/blob/memory.d.ts +9 -0
- package/dist/storage/blob/memory.d.ts.map +1 -0
- package/dist/storage/blob/memory.js +20 -0
- package/dist/storage/blob/memory.js.map +1 -0
- package/dist/storage/blob/s3.d.ts +16 -0
- package/dist/storage/blob/s3.d.ts.map +1 -0
- package/dist/storage/blob/s3.js +86 -0
- package/dist/storage/blob/s3.js.map +1 -0
- package/dist/storage/blob-store.d.ts +26 -0
- package/dist/storage/blob-store.d.ts.map +1 -0
- package/dist/storage/blob-store.js +2 -0
- package/dist/storage/blob-store.js.map +1 -0
- package/dist/storage/encrypted-adapter.d.ts +3 -3
- package/dist/storage/encrypted-adapter.js +3 -3
- package/dist/storage/folder-adapter.d.ts +8 -8
- package/dist/storage/folder-adapter.d.ts.map +1 -1
- package/dist/storage/folder-adapter.js +7 -7
- package/dist/storage/folder-reconcile.d.ts +12 -14
- package/dist/storage/folder-reconcile.d.ts.map +1 -1
- package/dist/storage/folder-reconcile.js +20 -14
- package/dist/storage/folder-reconcile.js.map +1 -1
- package/dist/storage/index.d.ts +0 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +0 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/indexeddb-adapter.d.ts.map +1 -1
- package/dist/storage/indexeddb-adapter.js +8 -9
- package/dist/storage/indexeddb-adapter.js.map +1 -1
- package/dist/storage/mirror.d.ts +68 -0
- package/dist/storage/mirror.d.ts.map +1 -0
- package/dist/storage/mirror.js +174 -0
- package/dist/storage/mirror.js.map +1 -0
- package/dist/storage/segment.d.ts +20 -0
- package/dist/storage/segment.d.ts.map +1 -0
- package/dist/storage/segment.js +27 -0
- package/dist/storage/segment.js.map +1 -0
- package/dist/storage/storage-provider.d.ts +36 -40
- package/dist/storage/storage-provider.d.ts.map +1 -1
- package/dist/storage/storage-provider.js +208 -117
- package/dist/storage/storage-provider.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/negentropy.d.ts +68 -0
- package/dist/sync/negentropy.d.ts.map +1 -0
- package/dist/sync/negentropy.js +379 -0
- package/dist/sync/negentropy.js.map +1 -0
- package/dist/sync/sync-engine.d.ts +45 -13
- package/dist/sync/sync-engine.d.ts.map +1 -1
- package/dist/sync/sync-engine.js +290 -207
- package/dist/sync/sync-engine.js.map +1 -1
- package/dist/sync/sync-messages.d.ts +42 -32
- package/dist/sync/sync-messages.d.ts.map +1 -1
- package/dist/sync/sync-messages.js +1 -1
- package/dist/sync/sync-messages.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/hash.d.ts +9 -0
- package/dist/utils/hash.d.ts.map +1 -1
- package/dist/utils/hash.js +33 -0
- package/dist/utils/hash.js.map +1 -1
- package/package.json +9 -3
- package/dist/storage/mst.d.ts +0 -121
- package/dist/storage/mst.d.ts.map +0 -1
- package/dist/storage/mst.js +0 -402
- package/dist/storage/mst.js.map +0 -1
- package/dist/sync/anti-entropy.d.ts +0 -49
- package/dist/sync/anti-entropy.d.ts.map +0 -1
- package/dist/sync/anti-entropy.js +0 -62
- 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 │
|
|
15
|
-
│ root signer, │ members × │ structural │ per │
|
|
16
|
-
│ UCAN, pairing│ pub/priv │ → UCAN │ space │
|
|
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:
|
|
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
|
|
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
|
|
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()` |
|
|
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()` |
|
|
496
|
-
| `
|
|
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
|
|
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
|
|
547
|
-
(`sys.role`, `sys.member`, `sys.invite`,
|
|
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.
|
|
590
|
-
|
|
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
|
|
595
|
-
whose space does not hash to its id
|
|
596
|
-
|
|
597
|
-
|
|
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/
|
|
743
|
-
|
|
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
|
-
|
|
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()` |
|
|
785
|
-
| `
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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>
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
|
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;
|
|
1205
|
-
|
|
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"}
|