@loro-dev/streams-crdt 0.14.0 → 0.15.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 +461 -147
- package/dist/flock.d.ts +2 -2
- package/dist/flock.js +4 -3
- package/dist/flock.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/loro.d.ts +2 -2
- package/dist/loro.js +2 -2
- package/dist/{stream-id-BuXMoCvo.d.ts → stream-id-CO65-58E.d.ts} +88 -52
- package/dist/{stream-id-CEp4zEQJ.js → stream-id-CggbAC6b.js} +142 -213
- package/dist/stream-id-CggbAC6b.js.map +1 -0
- package/package.json +1 -1
- package/dist/stream-id-CEp4zEQJ.js.map +0 -1
package/README.md
CHANGED
|
@@ -180,17 +180,13 @@ Only adjust if any of these apply:
|
|
|
180
180
|
```ts
|
|
181
181
|
import { LoroDoc } from "loro-crdt";
|
|
182
182
|
import {
|
|
183
|
-
createStreamUrl,
|
|
184
183
|
createLoroDocAdapter,
|
|
185
184
|
StreamsCrdt,
|
|
186
185
|
} from "@loro-dev/streams-crdt/loro";
|
|
187
186
|
|
|
188
187
|
const doc = new LoroDoc();
|
|
189
188
|
const transport = new StreamsCrdt({
|
|
190
|
-
streamUrl:
|
|
191
|
-
bucketId: "my-bucket",
|
|
192
|
-
streamId: "doc-1",
|
|
193
|
-
}),
|
|
189
|
+
streamUrl: "https://streams-api.loro.dev/ds/my-bucket/doc-1",
|
|
194
190
|
auth: async () => "<gateway-jwt>",
|
|
195
191
|
adapter: createLoroDocAdapter(doc),
|
|
196
192
|
// Optional TTL applied when the stream is created.
|
|
@@ -305,14 +301,10 @@ import { EphemeralStore } from "loro-crdt";
|
|
|
305
301
|
import {
|
|
306
302
|
EphemeralStreamCrdt,
|
|
307
303
|
EphemeralStoreAdaptor,
|
|
308
|
-
createStreamUrl,
|
|
309
304
|
} from "@loro-dev/streams-crdt/loro";
|
|
310
305
|
|
|
311
306
|
const store = new EphemeralStore();
|
|
312
|
-
const docStreamUrl =
|
|
313
|
-
bucketId: "my-bucket",
|
|
314
|
-
streamId: "doc-1",
|
|
315
|
-
});
|
|
307
|
+
const docStreamUrl = "https://streams-api.loro.dev/ds/my-bucket/doc-1";
|
|
316
308
|
|
|
317
309
|
const presence = new EphemeralStreamCrdt({
|
|
318
310
|
streamUrl: `${docStreamUrl}?ephemeral=presence`,
|
|
@@ -357,10 +349,10 @@ sync derive from that same id:
|
|
|
357
349
|
|
|
358
350
|
```ts
|
|
359
351
|
const roomId = "design-review-42";
|
|
360
|
-
const durableStreamUrl =
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
352
|
+
const durableStreamUrl = new URL(
|
|
353
|
+
`ds/my-bucket/${encodeURIComponent(`flock-${roomId}`)}`,
|
|
354
|
+
"https://streams-api.loro.dev/proxy/",
|
|
355
|
+
).toString();
|
|
364
356
|
|
|
365
357
|
const presenceStreamUrl = new URL(durableStreamUrl);
|
|
366
358
|
presenceStreamUrl.searchParams.set("ephemeral", "presence");
|
|
@@ -378,6 +370,10 @@ const presenceTransport = new EphemeralStreamCrdt({
|
|
|
378
370
|
});
|
|
379
371
|
```
|
|
380
372
|
|
|
373
|
+
The `ds/...` path is relative so a gateway path prefix such as `/proxy/` is
|
|
374
|
+
preserved. Validate dynamic bucket and stream segments with `isValidRillId()`
|
|
375
|
+
before constructing the URL; this rejects `.`/`..` before URL normalization.
|
|
376
|
+
|
|
381
377
|
`EphemeralStreamCrdt` does not create or delete the durable stream. It posts
|
|
382
378
|
updates to the ephemeral URL and opens SSE on the same URL with `live=sse`.
|
|
383
379
|
Ephemeral state is not persisted with the Flock document stream; it is scoped by
|
|
@@ -388,17 +384,13 @@ the URL path plus channel and is meant for current live state only.
|
|
|
388
384
|
```ts
|
|
389
385
|
import { Flock } from "@loro-dev/flock-wasm";
|
|
390
386
|
import {
|
|
391
|
-
createStreamUrl,
|
|
392
387
|
createFlockAdapter,
|
|
393
388
|
StreamsCrdt,
|
|
394
389
|
} from "@loro-dev/streams-crdt/flock";
|
|
395
390
|
|
|
396
391
|
const flock = new Flock();
|
|
397
392
|
const transport = new StreamsCrdt({
|
|
398
|
-
streamUrl:
|
|
399
|
-
bucketId: "my-bucket",
|
|
400
|
-
streamId: "room-1",
|
|
401
|
-
}),
|
|
393
|
+
streamUrl: "https://streams-api.loro.dev/ds/my-bucket/room-1",
|
|
402
394
|
auth: async () => "<gateway-jwt>",
|
|
403
395
|
adapter: createFlockAdapter(flock),
|
|
404
396
|
});
|
|
@@ -538,114 +530,432 @@ before their bytes are appended to Durable Streams. Auth stays separate:
|
|
|
538
530
|
gateway tokens authorize HTTP requests, while document keys protect document
|
|
539
531
|
bytes from the server and unauthorized readers.
|
|
540
532
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
533
|
+
### E2EE at a Glance
|
|
534
|
+
|
|
535
|
+
#### 1. What the application provides
|
|
536
|
+
|
|
537
|
+
The application injects a `PayloadProtectionProvider`, not a raw key. At the
|
|
538
|
+
streams-crdt API boundary, the provider supplies three things:
|
|
539
|
+
|
|
540
|
+
- `seal()` encrypts with the current write key and a fresh nonce, then returns
|
|
541
|
+
an opaque authenticated `header` plus provider-defined `sealed` bytes.
|
|
542
|
+
- `open()` parses that header, resolves the corresponding historical read key,
|
|
543
|
+
authenticates the ciphertext, and returns plaintext.
|
|
544
|
+
- `maxSealOverheadBytes` declares the maximum provider-added size so
|
|
545
|
+
streams-crdt can batch updates without speculatively sealing or consuming a
|
|
546
|
+
nonce.
|
|
547
|
+
|
|
548
|
+
The provider owns the audited AEAD implementation, nonce generation, sealed
|
|
549
|
+
layout, opaque header schema, logical-room AAD, current write key/epoch, and
|
|
550
|
+
historical key lookup. streams-crdt never receives a key directly and does not
|
|
551
|
+
implement MLS, device identity, epoch state, key storage, or product policy.
|
|
552
|
+
|
|
553
|
+
#### 2. What streams-crdt does
|
|
554
|
+
|
|
555
|
+
streams-crdt places that provider at the common durable payload boundary:
|
|
556
|
+
|
|
557
|
+
```text
|
|
558
|
+
local CRDT payload
|
|
559
|
+
-> batch and freeze
|
|
560
|
+
-> provider.seal()
|
|
561
|
+
-> streams-crdt envelope and transport framing
|
|
562
|
+
-> Durable Streams
|
|
563
|
+
|
|
564
|
+
Durable Streams payload
|
|
565
|
+
-> remove transport framing and parse the envelope
|
|
566
|
+
-> provider.open()
|
|
567
|
+
-> clear CRDT payload
|
|
568
|
+
-> apply to Loro or Flock
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
The same boundary covers updates, snapshots, bootstrap, catch-up, live
|
|
572
|
+
SSE/long-poll, 410 recovery, and write-only appends. Frozen update retries reuse
|
|
573
|
+
the same ciphertext instead of calling `seal()` and generating a second nonce.
|
|
574
|
+
Protected reads authenticate before adapter apply or cursor persistence and
|
|
575
|
+
fail closed on any envelope or provider error.
|
|
576
|
+
|
|
577
|
+
`streamUrl` remains an opaque transport URL and is not cryptographic identity.
|
|
578
|
+
The provider must bind a stable logical room identity itself when cross-room
|
|
579
|
+
replay protection is required. This boundary covers only durable `StreamsCrdt`
|
|
580
|
+
payloads: `EphemeralStreamCrdt` presence, awareness, cursor, selection, and
|
|
581
|
+
display-name payloads are outside it.
|
|
582
|
+
|
|
583
|
+
#### 3. What gets encoded
|
|
584
|
+
|
|
585
|
+
Every protected update batch and snapshot uses the same envelope:
|
|
586
|
+
|
|
587
|
+
```text
|
|
588
|
+
protectedEnvelope =
|
|
589
|
+
"LSCE"[4]
|
|
590
|
+
|| version[1]
|
|
591
|
+
|| payloadKind[1]
|
|
592
|
+
|| providerHeaderLength[2]
|
|
593
|
+
|| reserved[2]
|
|
594
|
+
|| providerHeader[H]
|
|
595
|
+
|| sealed[provider-defined]
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
The fixed envelope header is 10 bytes. `providerHeader` is 1–512 bytes, so the
|
|
599
|
+
prefix before `sealed` is `10 + H`, or 11–522 bytes. The provider header is
|
|
600
|
+
visible but authenticated; streams-crdt does not parse it.
|
|
601
|
+
|
|
602
|
+
For example, a provider using XChaCha20-Poly1305 commonly chooses this sealed
|
|
603
|
+
layout:
|
|
544
604
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
605
|
+
```text
|
|
606
|
+
sealed = nonce[24] || ciphertext[plaintext.length] || authenticationTag[16]
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
The 24-byte nonce is the random 192-bit XChaCha nonce. The 16-byte tag is the
|
|
610
|
+
128-bit Poly1305 authentication tag. XChaCha ciphertext has the same length as
|
|
611
|
+
its plaintext, so this provider layout adds `24 + 16 = 40` bytes. This layout
|
|
612
|
+
is an example, not a format imposed by streams-crdt.
|
|
613
|
+
|
|
614
|
+
The two durable payload classes then differ only in their surrounding framing:
|
|
615
|
+
|
|
616
|
+
```text
|
|
617
|
+
update append body = u32be(protectedEnvelope.length) || protectedEnvelope
|
|
618
|
+
snapshot PUT body = protectedEnvelope
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
If `H` is the provider-header length, a direct XChaCha layout adds the following
|
|
622
|
+
bytes relative to the plaintext passed to `seal()`:
|
|
623
|
+
|
|
624
|
+
| Payload | Size calculation | Added bytes |
|
|
625
|
+
| --- | --- | ---: |
|
|
626
|
+
| Update batch | outer length `4` + envelope `10` + header `H` + nonce/tag `40` | `54 + H` |
|
|
627
|
+
| Snapshot | envelope `10` + header `H` + nonce/tag `40` | `50 + H` |
|
|
628
|
+
|
|
629
|
+
For a 16-byte provider header, that is 70 bytes per encrypted update append and
|
|
630
|
+
66 bytes per encrypted snapshot. That provider's declared
|
|
631
|
+
`maxSealOverheadBytes` must cover `H + 40`; the fixed 10-byte envelope and the
|
|
632
|
+
update-only 4-byte outer length are accounted for separately by streams-crdt.
|
|
633
|
+
Compression may independently change the snapshot plaintext size before
|
|
634
|
+
sealing. The 40-byte streams-crdt AAD domain tag is reconstructed locally and
|
|
635
|
+
authenticated, not serialized as another 40 bytes on the wire.
|
|
636
|
+
|
|
637
|
+
The server can still observe the stream URL, provider header, offsets, total
|
|
638
|
+
payload sizes, timing, and auth metadata. It cannot read the CRDT payload or,
|
|
639
|
+
within one encrypted update batch, its update count and individual update
|
|
640
|
+
sizes.
|
|
641
|
+
|
|
642
|
+
### Minimal Provider Setup
|
|
549
643
|
|
|
550
|
-
|
|
551
|
-
|
|
644
|
+
The cryptographic and header helpers in this example are application
|
|
645
|
+
pseudocode, not exports from `@loro-dev/streams-crdt`. An application should
|
|
646
|
+
implement them with an audited AEAD library and an unambiguous binary encoding.
|
|
647
|
+
The only streams-crdt API being implemented here is
|
|
648
|
+
`PayloadProtectionProvider`.
|
|
552
649
|
|
|
553
650
|
```ts
|
|
554
|
-
|
|
555
|
-
|
|
651
|
+
import {
|
|
652
|
+
PayloadProtectionError,
|
|
653
|
+
type PayloadProtectionProvider,
|
|
654
|
+
} from "@loro-dev/streams-crdt/loro";
|
|
655
|
+
|
|
656
|
+
// This provider belongs to one application room session and captures that
|
|
657
|
+
// session's finalized write epoch and stable room AAD. Header parsing remains
|
|
658
|
+
// provider-owned. frameAad() must encode its inputs unambiguously.
|
|
659
|
+
const roomAad = encodeApplicationRoomIdentity(roomId);
|
|
660
|
+
const provider: PayloadProtectionProvider = {
|
|
661
|
+
// header + random 24-byte nonce + 16-byte tag, conservatively bounded.
|
|
662
|
+
maxSealOverheadBytes: 256,
|
|
663
|
+
|
|
664
|
+
async seal({ plaintext, additionalData }) {
|
|
665
|
+
const header = encodeLodyHeader({
|
|
666
|
+
epoch: roomSession.writeEpoch,
|
|
667
|
+
keyId: roomSession.writeKeyId,
|
|
668
|
+
});
|
|
669
|
+
const nonce = crypto.getRandomValues(new Uint8Array(24));
|
|
670
|
+
const ciphertextAndTag = await xchacha20poly1305Seal({
|
|
671
|
+
key: roomSession.writeKey,
|
|
672
|
+
nonce,
|
|
673
|
+
plaintext,
|
|
674
|
+
aad: frameAad(roomAad, additionalData(header)),
|
|
675
|
+
});
|
|
676
|
+
return { header, sealed: concatBytes(nonce, ciphertextAndTag) };
|
|
677
|
+
},
|
|
678
|
+
async open({ sealed, header, additionalData }) {
|
|
679
|
+
const historical = decodeLodyHeader(header);
|
|
680
|
+
// Resolve from logical room + provider-owned epoch/key id.
|
|
681
|
+
const key = await roomKeys.readKey(roomId, historical);
|
|
682
|
+
if (key == null) {
|
|
683
|
+
throw new PayloadProtectionError("missing_read_key");
|
|
684
|
+
}
|
|
685
|
+
return await xchacha20poly1305Open({
|
|
686
|
+
key,
|
|
687
|
+
nonce: sealed.slice(0, 24),
|
|
688
|
+
ciphertextAndTag: sealed.slice(24),
|
|
689
|
+
aad: frameAad(roomAad, additionalData),
|
|
690
|
+
});
|
|
691
|
+
},
|
|
692
|
+
};
|
|
556
693
|
|
|
557
694
|
const transport = new StreamsCrdt({
|
|
558
|
-
streamUrl,
|
|
695
|
+
streamUrl: "https://streams-api.loro.dev/ds/my-bucket/doc-1",
|
|
559
696
|
adapter: createLoroDocAdapter(doc),
|
|
560
697
|
e2ee: {
|
|
561
|
-
|
|
698
|
+
provider,
|
|
699
|
+
// Defaults shown explicitly:
|
|
562
700
|
readPolicy: "encrypted-only",
|
|
563
701
|
writePolicy: "encrypt",
|
|
564
|
-
encryption: {
|
|
565
|
-
// Stable application identity. Prefer bucket/stream identity over a full
|
|
566
|
-
// gateway URL so proxy/host changes do not break decryption.
|
|
567
|
-
scope: { bucketId: "my-bucket", streamId: "doc-1" },
|
|
568
|
-
writeKey: { id: "room-key-v1", key: roomKey },
|
|
569
|
-
// Optional. When omitted, writeKey is also the only read key.
|
|
570
|
-
readKeys: async () => [{ id: "room-key-v1", key: roomKey }],
|
|
571
|
-
},
|
|
572
702
|
},
|
|
573
703
|
});
|
|
574
704
|
```
|
|
575
705
|
|
|
576
|
-
|
|
577
|
-
plaintext length-prefix frame around each encrypted envelope, so catch-up reads
|
|
578
|
-
can concatenate several appends and clients can still recover payload
|
|
579
|
-
boundaries.
|
|
706
|
+
### Provider Contract
|
|
580
707
|
|
|
581
|
-
|
|
708
|
+
The package's exported TypeScript declarations are the source of truth. The
|
|
709
|
+
relevant contract, defined in [`src/types.ts`](./src/types.ts), is equivalent
|
|
710
|
+
to this abbreviated definition:
|
|
582
711
|
|
|
583
|
-
|
|
712
|
+
```ts
|
|
713
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
714
|
+
type PayloadProtectionKind = "update_batch" | "snapshot";
|
|
584
715
|
|
|
585
|
-
|
|
716
|
+
interface PayloadProtectionContext {
|
|
717
|
+
readonly protocol: "loro-streams-crdt-payload-protection";
|
|
718
|
+
readonly version: 2;
|
|
719
|
+
readonly kind: PayloadProtectionKind;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
interface PayloadProtectionProvider {
|
|
723
|
+
readonly maxSealOverheadBytes: number;
|
|
724
|
+
|
|
725
|
+
seal(input: {
|
|
726
|
+
readonly plaintext: Uint8Array;
|
|
727
|
+
readonly context: PayloadProtectionContext;
|
|
728
|
+
readonly additionalData: (header: Uint8Array) => Uint8Array;
|
|
729
|
+
}): MaybePromise<{
|
|
730
|
+
readonly header: Uint8Array;
|
|
731
|
+
readonly sealed: Uint8Array;
|
|
732
|
+
}>;
|
|
733
|
+
|
|
734
|
+
open(input: {
|
|
735
|
+
readonly sealed: Uint8Array;
|
|
736
|
+
readonly header: Uint8Array;
|
|
737
|
+
readonly context: PayloadProtectionContext;
|
|
738
|
+
readonly additionalData: Uint8Array;
|
|
739
|
+
}): MaybePromise<Uint8Array>;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
interface E2eeOptions {
|
|
743
|
+
readonly provider?: PayloadProtectionProvider;
|
|
744
|
+
readonly readPolicy?: "encrypted-only" | "allow-plaintext";
|
|
745
|
+
readonly writePolicy?: "encrypt" | "plaintext";
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
The provider is optional only when `writePolicy` is `"plaintext"`; protected
|
|
750
|
+
reads still need one to open encrypted history.
|
|
751
|
+
|
|
752
|
+
The inputs have these exact meanings:
|
|
753
|
+
|
|
754
|
+
| Field | Meaning |
|
|
755
|
+
| --- | --- |
|
|
756
|
+
| `plaintext` in `seal()` | For `update_batch`, an inner length-prefixed batch containing one or more CRDT update blobs. For `snapshot`, the adapter snapshot after optional `snapshotCodec.compress`. |
|
|
757
|
+
| `context` | Stable streams-crdt protocol/version/payload-kind context. It contains no URL, bucket, stream, room, epoch, or key id. |
|
|
758
|
+
| `additionalData(header)` in `seal()` | Builds the exact streams-crdt-owned AAD for the final provider header. It must be called exactly once. |
|
|
759
|
+
| `header` | Provider-owned, plaintext metadata used to select an algorithm or historical key. It must be 1–512 bytes and must be authenticated. |
|
|
760
|
+
| `sealed` | Provider-owned non-empty bytes, normally nonce plus ciphertext and authentication tag. |
|
|
761
|
+
| `additionalData` in `open()` | The same streams-crdt-owned AAD bytes produced for that envelope at write time. |
|
|
762
|
+
|
|
763
|
+
For cross-room replay protection, use keys isolated per logical room or combine
|
|
764
|
+
application room AAD and streams-crdt AAD without ambiguity. For example, use
|
|
765
|
+
domain tags plus length-prefixed fields; do not use plain string concatenation
|
|
766
|
+
where two input pairs can produce the same bytes. `seal()` must return the exact
|
|
767
|
+
header passed to `additionalData(header)`.
|
|
768
|
+
|
|
769
|
+
The `sealed` layout stays provider-owned. For example, it may be a random
|
|
770
|
+
192-bit XChaCha20-Poly1305 nonce followed by ciphertext and tag; streams-crdt
|
|
771
|
+
does not parse the nonce or enforce an algorithm suite.
|
|
772
|
+
|
|
773
|
+
`seal()` runs exactly once after a logical append is frozen for retry, so a
|
|
774
|
+
retry reuses the same ciphertext. It does not define an epoch transition
|
|
775
|
+
barrier. For a Lody write-epoch change, drain pending appends with
|
|
776
|
+
`waitUntilSynced()`, close the old room transport, finalize the epoch change,
|
|
777
|
+
then create a replacement room transport/provider. Only that room session must
|
|
778
|
+
be rebuilt, not the whole repo. The replacement provider may still resolve old
|
|
779
|
+
headers dynamically in `open()`.
|
|
780
|
+
|
|
781
|
+
Encrypted streams still use `application/octet-stream`. The server stores
|
|
782
|
+
opaque bodies and never parses the protected envelope. Update appends retain
|
|
783
|
+
their outer item-length framing. Snapshot PUT bodies contain the snapshot
|
|
784
|
+
envelope directly; they do not use the update append's outer item frame.
|
|
785
|
+
|
|
786
|
+
### Payload Lifecycle and Framing
|
|
787
|
+
|
|
788
|
+
E2EE wraps the existing transport payloads; it does not replace Durable
|
|
789
|
+
Streams HTTP, CAS, offset, multipart, or live-event framing.
|
|
790
|
+
|
|
791
|
+
#### Update write path
|
|
792
|
+
|
|
793
|
+
One `seal()` call protects one **frozen append batch**, not one individual CRDT
|
|
794
|
+
update. Before freezing an append, the transport may merge FIFO queued batches
|
|
795
|
+
while the estimated encoded body remains within its 256 KiB append budget. It
|
|
796
|
+
then performs this pipeline:
|
|
586
797
|
|
|
587
798
|
```text
|
|
588
|
-
adapter
|
|
589
|
-
->
|
|
590
|
-
->
|
|
591
|
-
->
|
|
592
|
-
->
|
|
799
|
+
adapter export/subscription
|
|
800
|
+
-> clearUpdates[] one or more opaque CRDT update blobs
|
|
801
|
+
-> encodeItems(clearUpdates) inner update-batch plaintext
|
|
802
|
+
-> provider.seal(kind = "update_batch") exactly once for this frozen append
|
|
803
|
+
-> protectedEnvelope
|
|
804
|
+
-> encodeItems([protectedEnvelope]) outer Durable Streams append frame
|
|
805
|
+
-> POST/CAS body
|
|
593
806
|
```
|
|
594
807
|
|
|
595
|
-
|
|
808
|
+
`encodeItems()` is a sequence of unsigned 32-bit big-endian length prefixes:
|
|
596
809
|
|
|
597
810
|
```text
|
|
598
|
-
|
|
599
|
-
|
|
811
|
+
innerUpdateBatch =
|
|
812
|
+
u32be(update_1.length) || update_1
|
|
813
|
+
|| u32be(update_2.length) || update_2
|
|
814
|
+
|| ...
|
|
815
|
+
|
|
816
|
+
protectedEnvelope =
|
|
817
|
+
fixedEnvelopeHeader || providerHeader || providerSealedBytes
|
|
818
|
+
|
|
819
|
+
encryptedAppendBody =
|
|
820
|
+
u32be(protectedEnvelope.length) || protectedEnvelope
|
|
600
821
|
```
|
|
601
822
|
|
|
602
|
-
|
|
823
|
+
The provider receives `innerUpdateBatch` as `seal().plaintext`. The outer
|
|
824
|
+
length prefix is added only after sealing so streams-crdt readers can recover
|
|
825
|
+
the protected item from the stored append. The server still treats the whole
|
|
826
|
+
append body as opaque and does not learn the number or individual sizes of the
|
|
827
|
+
CRDT updates inside the batch. Once the protected append and producer sequence
|
|
828
|
+
are frozen, retries reuse the same bytes and do not call `seal()` or generate
|
|
829
|
+
another nonce.
|
|
830
|
+
|
|
831
|
+
With `writePolicy: "plaintext"`, there is no inner encrypted batch or protected
|
|
832
|
+
envelope: the append body is simply `encodeItems(clearUpdates)`.
|
|
833
|
+
|
|
834
|
+
#### Update read path
|
|
835
|
+
|
|
836
|
+
Bootstrap tails, catch-up responses, long-poll responses, and SSE events all
|
|
837
|
+
reuse the same reverse path:
|
|
838
|
+
|
|
839
|
+
```text
|
|
840
|
+
Durable Streams append bytes
|
|
841
|
+
-> decodeItems(outer append frame)
|
|
842
|
+
-> detect and parse protectedEnvelope
|
|
843
|
+
-> provider.open(kind = "update_batch")
|
|
844
|
+
-> innerUpdateBatch plaintext
|
|
845
|
+
-> decodeItems(innerUpdateBatch)
|
|
846
|
+
-> clearUpdates[]
|
|
847
|
+
-> adapter.applyRemoteUpdates(clearUpdates, remoteVersion)
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
An encrypted outer item must open to one valid inner `encodeItems()` payload.
|
|
851
|
+
It may expand to several CRDT update blobs before the adapter is called. Under
|
|
852
|
+
`allow-plaintext`, a non-envelope outer item is passed to the adapter as one
|
|
853
|
+
clear update. Under `encrypted-only`, that same item fails before adapter apply
|
|
854
|
+
or cursor persistence.
|
|
855
|
+
|
|
856
|
+
#### Snapshot write and read paths
|
|
857
|
+
|
|
858
|
+
Snapshots use the same provider envelope but not the update batch's inner or
|
|
859
|
+
outer item framing:
|
|
860
|
+
|
|
861
|
+
```text
|
|
862
|
+
adapter.exportSnapshot()
|
|
863
|
+
-> snapshotCodec.compress?()
|
|
864
|
+
-> provider.seal(kind = "snapshot")
|
|
865
|
+
-> protectedEnvelope
|
|
866
|
+
-> PUT snapshot body directly
|
|
867
|
+
|
|
868
|
+
bootstrap snapshot part
|
|
869
|
+
-> parse protectedEnvelope
|
|
870
|
+
-> provider.open(kind = "snapshot")
|
|
871
|
+
-> snapshotCodec.decompress?()
|
|
872
|
+
-> adapter.applySnapshot()
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
The provider therefore sees the optionally compressed snapshot bytes as
|
|
876
|
+
`seal().plaintext`. A selected snapshot is exported, compressed, and sealed
|
|
877
|
+
once before its PUT; an HTTP-level resend of that prepared request reuses the
|
|
878
|
+
same body. A later scheduled snapshot may export and seal newer state again.
|
|
879
|
+
Snapshot offset headers and bootstrap multipart boundaries remain outside the
|
|
880
|
+
envelope because the service must read them.
|
|
881
|
+
|
|
882
|
+
#### Bootstrap, catch-up, live, and recovery
|
|
883
|
+
|
|
884
|
+
All durable paths converge on the two update hooks and two snapshot hooks
|
|
885
|
+
described above:
|
|
886
|
+
|
|
887
|
+
| Path | Processing order |
|
|
888
|
+
| --- | --- |
|
|
889
|
+
| Initial bootstrap | Open/decompress/apply the snapshot when present, then open and apply the retained update tail. |
|
|
890
|
+
| `sync()` / `catchup()` | Deframe and open every returned update batch before adapter apply. |
|
|
891
|
+
| `join()` live SSE or long-poll | Use the same update read path for every event; there is no live-specific plaintext bypass. |
|
|
892
|
+
| 410 recovery | Re-bootstrap through the same snapshot and update hooks before resuming live reads. |
|
|
893
|
+
| Local join/sync writes | Merge, freeze, seal, and append through the same update write path. |
|
|
894
|
+
| `appendWriteOnly()` | Uses the same frozen update write path but performs no remote read or snapshot upload. |
|
|
895
|
+
| Snapshot upload | Compress, seal, and PUT through the same snapshot write path. |
|
|
896
|
+
|
|
897
|
+
Protected reads complete authentication and decoding before `applySnapshot()`,
|
|
898
|
+
`applyRemoteUpdates()`, local durability hooks, or remote cursor persistence.
|
|
899
|
+
Any failure therefore leaves the corresponding remote cursor unadvanced.
|
|
900
|
+
|
|
901
|
+
### Provider Envelope and AAD
|
|
902
|
+
|
|
903
|
+
Provider envelope v2 is:
|
|
603
904
|
|
|
604
905
|
```text
|
|
605
906
|
magic 4 bytes "LSCE"
|
|
606
|
-
version 1 byte
|
|
907
|
+
version 1 byte 0x02
|
|
607
908
|
kind 1 byte 0x01 update_batch, 0x02 snapshot
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
key_id key_id_len bytes, UTF-8
|
|
613
|
-
nonce nonce_len bytes
|
|
614
|
-
ciphertext_and_tag remaining bytes
|
|
909
|
+
provider_header_len 2 bytes unsigned big-endian; 1..512
|
|
910
|
+
reserved 2 bytes both 0
|
|
911
|
+
provider_header provider_header_len bytes, opaque
|
|
912
|
+
sealed remaining bytes, provider-defined and non-empty
|
|
615
913
|
```
|
|
616
914
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
"protocol": "loro-streams-crdt-payload-protection",
|
|
623
|
-
"version": 1,
|
|
624
|
-
"kind": "update_batch",
|
|
625
|
-
"suite": "aes_256_gcm",
|
|
626
|
-
"keyId": "room-key-v1",
|
|
627
|
-
"scope": { "bucketId": "my-bucket", "streamId": "doc-1" }
|
|
628
|
-
}
|
|
915
|
+
The common AAD is an unambiguous byte sequence:
|
|
916
|
+
|
|
917
|
+
```text
|
|
918
|
+
UTF8("loro-streams-crdt-payload-protection/v2\0")
|
|
919
|
+
|| exact envelope bytes from magic through provider_header
|
|
629
920
|
```
|
|
630
921
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
922
|
+
This streams-crdt-owned AAD binds protocol/version, payload kind, and the
|
|
923
|
+
provider's entire opaque header. Algorithm, epoch, key-id, application room,
|
|
924
|
+
and stream identity schemas are not part of the streams-crdt protocol. A
|
|
925
|
+
provider that needs cross-room replay protection must capture a stable logical
|
|
926
|
+
room identity and combine it unambiguously with `additionalData`, as in the
|
|
927
|
+
example above. Do not use a gateway origin as room identity unless moving that
|
|
928
|
+
origin is intentionally a cryptographic migration.
|
|
929
|
+
|
|
930
|
+
The service does not parse the v2 envelope. It remains inside the
|
|
931
|
+
application payload: append/CAS headers, offsets, multipart boundaries, and
|
|
932
|
+
snapshot offsets stay outside so Durable Streams can route and retain bytes.
|
|
933
|
+
|
|
934
|
+
`maxSealOverheadBytes` must be a safe integer from 0 through 4096 and must bound
|
|
935
|
+
`header.length + sealed.length - plaintext.length`. The transport uses that
|
|
936
|
+
bound to merge queued CRDT batches without speculative sealing or nonce use;
|
|
937
|
+
actual output above the declaration fails closed.
|
|
938
|
+
|
|
939
|
+
### Draft Compatibility
|
|
940
|
+
|
|
941
|
+
Provider v2 is still unreleased. Ciphertext produced by earlier revisions of
|
|
942
|
+
this draft branch used URL-derived AAD and is not compatible with the final
|
|
943
|
+
caller-owned AAD contract; discard or recreate that draft data rather than
|
|
944
|
+
adding a production dual-reader for an unpublished format.
|
|
635
945
|
|
|
636
946
|
### Mode Matrix
|
|
637
947
|
|
|
638
948
|
`e2ee` supports a few distinct shapes. Choose one explicitly:
|
|
639
949
|
|
|
640
|
-
| Use case | `readPolicy` | `writePolicy` | Required
|
|
950
|
+
| Use case | `readPolicy` | `writePolicy` | Required protection config | Remote plaintext accepted? | Local writes sent as |
|
|
641
951
|
| --- | --- | --- | --- | --- | --- |
|
|
642
952
|
| Existing plaintext stream | omitted | omitted | none | Yes | plaintext |
|
|
643
|
-
|
|
|
644
|
-
| Mixed
|
|
953
|
+
| Provider private reader/writer | `encrypted-only` (default) | `encrypt` (default) | `streamUrl` + `provider` | No | provider v2 |
|
|
954
|
+
| Mixed provider reader/writer | `allow-plaintext` | `encrypt` | `streamUrl` + `provider` | Yes | provider v2 |
|
|
645
955
|
| Public write-only writer | `allow-plaintext` | `plaintext` | none | Yes | plaintext via `appendWriteOnly()` |
|
|
646
|
-
|
|
|
956
|
+
| Provider reader without writes | `encrypted-only` | `plaintext` | `streamUrl` + `provider` | No | plaintext if local writes are exported |
|
|
647
957
|
|
|
648
|
-
There is no separate transport-level
|
|
958
|
+
There is no separate transport-level read-only mode. The last row is
|
|
649
959
|
only appropriate when the local CRDT is treated as read-only or the caller's
|
|
650
960
|
auth rejects writes. `writePolicy: "plaintext"` avoids requiring a write key; it
|
|
651
961
|
does not suppress local export attempts by itself.
|
|
@@ -664,6 +974,15 @@ That also means one stream must use exactly one CRDT family:
|
|
|
664
974
|
protection is enabled or not. The decrypted inner update bytes and snapshots
|
|
665
975
|
are CRDT-specific and are not cross-compatible.
|
|
666
976
|
|
|
977
|
+
The examples below use separate stream URLs and room-bound providers:
|
|
978
|
+
|
|
979
|
+
```ts
|
|
980
|
+
const streamUrl = "https://streams-api.loro.dev/ds/my-bucket/doc-1";
|
|
981
|
+
const flockStreamUrl = "https://streams-api.loro.dev/ds/my-bucket/flock-1";
|
|
982
|
+
const loroProvider = createRoomProvider("doc-1");
|
|
983
|
+
const flockProvider = createRoomProvider("flock-1");
|
|
984
|
+
```
|
|
985
|
+
|
|
667
986
|
Protected Loro room:
|
|
668
987
|
|
|
669
988
|
```ts
|
|
@@ -678,10 +997,7 @@ const loroTransport = new StreamsCrdt({
|
|
|
678
997
|
streamUrl,
|
|
679
998
|
adapter: createLoroDocAdapter(doc),
|
|
680
999
|
e2ee: {
|
|
681
|
-
|
|
682
|
-
scope: { bucketId: "my-bucket", streamId: "doc-1" },
|
|
683
|
-
writeKey: { id: "room-key-v1", key: roomKey },
|
|
684
|
-
},
|
|
1000
|
+
provider: loroProvider,
|
|
685
1001
|
},
|
|
686
1002
|
});
|
|
687
1003
|
```
|
|
@@ -697,13 +1013,10 @@ import {
|
|
|
697
1013
|
|
|
698
1014
|
const flock = new Flock();
|
|
699
1015
|
const flockTransport = new StreamsCrdt({
|
|
700
|
-
streamUrl,
|
|
1016
|
+
streamUrl: flockStreamUrl,
|
|
701
1017
|
adapter: createFlockAdapter(flock),
|
|
702
1018
|
e2ee: {
|
|
703
|
-
|
|
704
|
-
scope: { bucketId: "my-bucket", streamId: "room-1" },
|
|
705
|
-
writeKey: { id: "room-key-v1", key: roomKey },
|
|
706
|
-
},
|
|
1019
|
+
provider: flockProvider,
|
|
707
1020
|
},
|
|
708
1021
|
});
|
|
709
1022
|
```
|
|
@@ -722,12 +1035,7 @@ const privateReaderWriter = new StreamsCrdt({
|
|
|
722
1035
|
streamUrl,
|
|
723
1036
|
adapter: createLoroDocAdapter(doc),
|
|
724
1037
|
e2ee: {
|
|
725
|
-
|
|
726
|
-
scope: { bucketId: "my-bucket", streamId: "doc-1" },
|
|
727
|
-
writeKey: { id: "private-v1", key: roomKey },
|
|
728
|
-
// Optional. Keep old keys here during rotation.
|
|
729
|
-
readKeys: async () => [{ id: "private-v1", key: roomKey }],
|
|
730
|
-
},
|
|
1038
|
+
provider,
|
|
731
1039
|
},
|
|
732
1040
|
});
|
|
733
1041
|
```
|
|
@@ -745,10 +1053,7 @@ const privateReaderWriter = new StreamsCrdt({
|
|
|
745
1053
|
e2ee: {
|
|
746
1054
|
readPolicy: "allow-plaintext",
|
|
747
1055
|
writePolicy: "encrypt",
|
|
748
|
-
|
|
749
|
-
scope: { bucketId: "my-bucket", streamId: "doc-1" },
|
|
750
|
-
writeKey: { id: "private-v1", key: roomKey },
|
|
751
|
-
},
|
|
1056
|
+
provider,
|
|
752
1057
|
},
|
|
753
1058
|
});
|
|
754
1059
|
```
|
|
@@ -774,14 +1079,20 @@ const appended = await publicWriter.appendWriteOnly();
|
|
|
774
1079
|
|
|
775
1080
|
- If `e2ee` is present and no policies are specified, the defaults
|
|
776
1081
|
are `readPolicy: "encrypted-only"` and `writePolicy: "encrypt"`.
|
|
777
|
-
- `
|
|
778
|
-
|
|
779
|
-
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1082
|
+
- `streamUrl` is required, remains opaque, and is never parsed to invent a
|
|
1083
|
+
security identity.
|
|
1084
|
+
- streams-crdt does not bind ciphertext to `streamUrl`. The provider/application
|
|
1085
|
+
must authenticate a stable logical room identity or use keys isolated per
|
|
1086
|
+
room. A provider configured for the wrong room must fail authentication.
|
|
1087
|
+
- `PayloadProtectionProviderConfig` (alias `E2eeProviderConfig`) is a scope-free
|
|
1088
|
+
`{ provider, readPolicy?, writePolicy? }` shape suitable for a repo to forward
|
|
1089
|
+
directly. Set `payloadProtectionRequired: true` once protection is enabled at
|
|
1090
|
+
repo level so a missing room config throws instead of becoming plaintext.
|
|
1091
|
+
This flag checks config presence only; it does not override an explicit
|
|
1092
|
+
`allow-plaintext` / `plaintext` policy such as public write-only mode.
|
|
1093
|
+
- Lody rekey is an application-owned room-session barrier: drain, close, switch
|
|
1094
|
+
the finalized write epoch, and create a new room transport. Historical reads
|
|
1095
|
+
may resolve provider headers dynamically.
|
|
785
1096
|
- `appendWriteOnly()` is the write-only path. It only posts local CRDT batches
|
|
786
1097
|
that this transport observed after construction. It does not bootstrap, catch
|
|
787
1098
|
up, join SSE, upload snapshots, apply remote updates, or persist a remote
|
|
@@ -796,12 +1107,15 @@ const appended = await publicWriter.appendWriteOnly();
|
|
|
796
1107
|
cannot read private state should not publish full-document snapshots.
|
|
797
1108
|
- Protected read failures stop before `applySnapshot()`,
|
|
798
1109
|
`applyRemoteUpdates()`, local durability hooks, or remote cursor persistence.
|
|
799
|
-
-
|
|
800
|
-
|
|
1110
|
+
- A recognized protected envelope never falls back to plaintext after an
|
|
1111
|
+
authentication failure, unknown version/key/epoch, wrong scope/kind, or
|
|
1112
|
+
tamper. Under `encrypted-only`, bytes without the envelope magic are also
|
|
1113
|
+
rejected as `plaintext_forbidden`. `allow-plaintext` intentionally accepts
|
|
1114
|
+
non-envelope bytes and is therefore not an encrypted-only room policy.
|
|
1115
|
+
- Keep historical keys until retained protected updates and snapshots no longer
|
|
801
1116
|
reference them.
|
|
802
|
-
-
|
|
803
|
-
|
|
804
|
-
earlier under encryption than it would in plaintext mode.
|
|
1117
|
+
- Provider batching uses only declared bounded overhead. It never calls
|
|
1118
|
+
`seal()` while estimating a merge and validates the actual frozen output.
|
|
805
1119
|
|
|
806
1120
|
If `e2ee` is configured and `readPolicy` is left at the default
|
|
807
1121
|
`"encrypted-only"`, remote plaintext updates fail with
|
|
@@ -815,18 +1129,24 @@ E2EE-related transport failures surface as
|
|
|
815
1129
|
| Reason | Meaning |
|
|
816
1130
|
| --- | --- |
|
|
817
1131
|
| `plaintext_forbidden` | A reader configured as `encrypted-only` saw plaintext data in the stream. |
|
|
818
|
-
| `missing_read_key` | The
|
|
819
|
-
| `decrypt_failed` |
|
|
820
|
-
| `invalid_envelope` | The payload was not a supported streams-crdt
|
|
1132
|
+
| `missing_read_key` | The provider cannot resolve its opaque header to a historical read key. |
|
|
1133
|
+
| `decrypt_failed` | The provider AEAD open failed. Common causes are a wrong key, wrong scope, or tampered bytes. |
|
|
1134
|
+
| `invalid_envelope` | The payload was not a supported streams-crdt protected envelope/version. |
|
|
821
1135
|
| `wrong_payload_kind` | An encrypted snapshot was used where an update batch was expected, or the reverse. |
|
|
822
1136
|
| `encrypt_failed` | Local write-side encryption could not proceed because the key/config/input was invalid. |
|
|
823
1137
|
|
|
824
1138
|
Callers usually treat these as operator or configuration errors, not transient
|
|
825
|
-
network failures. They are always returned as `retryable: false`.
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1139
|
+
network failures. They are always returned as `retryable: false`. The public
|
|
1140
|
+
`TransportError` carries only the stable `reason` and a stable redacted message;
|
|
1141
|
+
it never copies provider causes, key ids, or epochs into the default error path.
|
|
1142
|
+
|
|
1143
|
+
E2EE does **not** hide stream URLs, the opaque provider header, offsets, total
|
|
1144
|
+
append/snapshot sizes, timing, or auth metadata. It hides the count and
|
|
1145
|
+
individual sizes of updates within one encrypted append batch, but it is not a
|
|
1146
|
+
traffic-analysis defense. It also does not by itself detect a malicious server
|
|
1147
|
+
rolling back or forking history. In mixed `allow-plaintext` mode, no format can
|
|
1148
|
+
distinguish deliberate plaintext from ciphertext whose magic was stripped; use
|
|
1149
|
+
`encrypted-only` for a no-downgrade room.
|
|
830
1150
|
|
|
831
1151
|
## Remote Cursor Store
|
|
832
1152
|
|
|
@@ -870,10 +1190,7 @@ cursor advancement. The transport advances the cursor only after:
|
|
|
870
1190
|
|
|
871
1191
|
```ts
|
|
872
1192
|
const transport = new StreamsCrdt({
|
|
873
|
-
streamUrl:
|
|
874
|
-
bucketId: "my-bucket",
|
|
875
|
-
streamId: "doc-1",
|
|
876
|
-
}),
|
|
1193
|
+
streamUrl: "https://streams-api.loro.dev/ds/my-bucket/doc-1",
|
|
877
1194
|
adapter: createLoroDocAdapter(doc),
|
|
878
1195
|
beforeRemoteCursorSave: async ({ cursor, source }) => {
|
|
879
1196
|
// Persist local CRDT state before cursor advances.
|
|
@@ -896,7 +1213,6 @@ when you also persist **and restore** the local CRDT state for the same stream.
|
|
|
896
1213
|
```ts
|
|
897
1214
|
import { LoroDoc } from "loro-crdt";
|
|
898
1215
|
import {
|
|
899
|
-
createStreamUrl,
|
|
900
1216
|
createLoroDocAdapter,
|
|
901
1217
|
IndexedDbRemoteCursorStore,
|
|
902
1218
|
StreamsCrdt,
|
|
@@ -906,10 +1222,7 @@ import {
|
|
|
906
1222
|
const doc = (await restorePersistedDoc("doc-1")) ?? new LoroDoc();
|
|
907
1223
|
|
|
908
1224
|
const transport = new StreamsCrdt({
|
|
909
|
-
streamUrl:
|
|
910
|
-
bucketId: "my-bucket",
|
|
911
|
-
streamId: "doc-1",
|
|
912
|
-
}),
|
|
1225
|
+
streamUrl: "https://streams-api.loro.dev/ds/my-bucket/doc-1",
|
|
913
1226
|
auth: async () => "<gateway-jwt>",
|
|
914
1227
|
adapter: createLoroDocAdapter(doc),
|
|
915
1228
|
// Step 2: use a persistent cursor store.
|
|
@@ -991,7 +1304,6 @@ Then wire the exported hooks directly into `snapshotCodec`:
|
|
|
991
1304
|
import { LoroDoc } from "loro-crdt";
|
|
992
1305
|
import {
|
|
993
1306
|
createLoroDocAdapter,
|
|
994
|
-
createStreamUrl,
|
|
995
1307
|
StreamsCrdt,
|
|
996
1308
|
} from "@loro-dev/streams-crdt/loro";
|
|
997
1309
|
import {
|
|
@@ -1001,10 +1313,7 @@ import {
|
|
|
1001
1313
|
|
|
1002
1314
|
const doc = new LoroDoc();
|
|
1003
1315
|
const transport = new StreamsCrdt({
|
|
1004
|
-
streamUrl:
|
|
1005
|
-
bucketId: "my-bucket",
|
|
1006
|
-
streamId: "doc-1",
|
|
1007
|
-
}),
|
|
1316
|
+
streamUrl: "https://streams-api.loro.dev/ds/my-bucket/doc-1",
|
|
1008
1317
|
adapter: createLoroDocAdapter(doc),
|
|
1009
1318
|
snapshotCodec: { compress, decompress },
|
|
1010
1319
|
});
|
|
@@ -1099,12 +1408,18 @@ const transport = new StreamsCrdt({
|
|
|
1099
1408
|
- `TransportRoomStatus` — `"joined" | "reconnecting" | "disconnected" | "error"`
|
|
1100
1409
|
- `SnapshotCodec`, `SnapshotTransformHook` — full snapshot encode/decode hooks
|
|
1101
1410
|
- `WriteOnlyAppendResult` — result returned by `appendWriteOnly()`
|
|
1102
|
-
- `E2eeError` —
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1411
|
+
- `E2eeError` — alias for `PayloadProtectionError`
|
|
1412
|
+
- `E2eeOptions`, `E2eeProvider`, `E2eeProviderConfig`, `E2eeReadPolicy`,
|
|
1413
|
+
`E2eeWritePolicy` — E2EE configuration types
|
|
1414
|
+
- `PayloadProtectionProvider`, `PayloadProtectionProviderConfig`,
|
|
1415
|
+
`PayloadProtectionOptions` — provider implementation and room configuration
|
|
1416
|
+
- `PayloadProtectionSealInput`, `PayloadProtectionSealResult`,
|
|
1417
|
+
`PayloadProtectionOpenInput` — exact `seal()` / `open()` byte contracts
|
|
1418
|
+
- `PayloadProtectionContext`, `PayloadProtectionKind`,
|
|
1419
|
+
`PayloadProtectionReadPolicy`, `PayloadProtectionWritePolicy` — stable
|
|
1420
|
+
protocol context and policy types
|
|
1421
|
+
- `PayloadProtectionError`, `PayloadProtectionFailureReason` — provider and
|
|
1422
|
+
transport failure classification
|
|
1108
1423
|
- `SnapshotUploadOptions` — snapshot upload configuration
|
|
1109
1424
|
- `RemoteCursor` — replay progress metadata
|
|
1110
1425
|
- `RemoteCursorStore` — abstract cursor storage interface
|
|
@@ -1114,8 +1429,7 @@ const transport = new StreamsCrdt({
|
|
|
1114
1429
|
- `IndexedDbRemoteCursorStore` — persistent cursor store (requires local CRDT persistence)
|
|
1115
1430
|
- `IndexedDbRemoteCursorStoreOptions` — IndexedDB store config
|
|
1116
1431
|
- `createInitialRemoteCursor(...)` — seed a cursor store
|
|
1117
|
-
- `
|
|
1118
|
-
- `isValidBucketId(...)`, `isValidRillId(...)` — ID validation helpers
|
|
1432
|
+
- `isValidRillId(...)` — stream ID validation helper
|
|
1119
1433
|
|
|
1120
1434
|
### Loro Entry Point
|
|
1121
1435
|
|