@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 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: createStreamUrl({
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 = createStreamUrl({
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 = createStreamUrl({
361
- bucketId: "my-bucket",
362
- streamId: `flock-${roomId}`,
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: createStreamUrl({
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
- `payloadProtection` remains available as a deprecated alias for compatibility.
542
- Do not pass both `e2ee` and `payloadProtection` on the same `StreamsCrdt`
543
- instance.
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
- When `e2ee` is omitted, streams-crdt keeps the current plaintext
546
- wire format. When it is present, the transport still uses
547
- `application/octet-stream`, but update batches and snapshots may carry an
548
- encrypted envelope inside the existing length-prefixed framing.
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
- For the design rationale and wire format, see
551
- [`docs/plans/2026-04-09-streams-crdt-payload-protection-design.md`](../../docs/plans/2026-04-09-streams-crdt-payload-protection-design.md).
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
- const roomKey = new Uint8Array(32); // Load this from your own key store.
555
- crypto.getRandomValues(roomKey);
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
- // Defaults when e2ee is present:
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
- Encrypted streams still use `application/octet-stream`. The transport keeps a
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
- ### E2EE Protocol Summary
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
- E2EE wraps the existing transport framing; it does not replace it.
712
+ ```ts
713
+ type MaybePromise<T> = T | Promise<T>;
714
+ type PayloadProtectionKind = "update_batch" | "snapshot";
584
715
 
585
- Encrypted update appends use this pipeline:
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.exportUpdates()
589
- -> encodeItems(clearUpdates)
590
- -> encrypt(kind = "update_batch")
591
- -> encodeItems([encryptedEnvelope])
592
- -> POST body
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
- Encrypted snapshots use:
808
+ `encodeItems()` is a sequence of unsigned 32-bit big-endian length prefixes:
596
809
 
597
810
  ```text
598
- exportSnapshot -> snapshotCodec.compress -> encrypt(kind = "snapshot")
599
- decrypt(kind = "snapshot") -> snapshotCodec.decompress -> applySnapshot
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
- Envelope v1 is:
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 0x01
907
+ version 1 byte 0x02
607
908
  kind 1 byte 0x01 update_batch, 0x02 snapshot
608
- suite 1 byte 0x01 aes_256_gcm
609
- key_id_len 1 byte
610
- nonce_len 1 byte expected 12
611
- reserved 1 byte 0
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
- AEAD additional authenticated data (AAD) is constructed from stable local
618
- context:
619
-
620
- ```json
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
- AAD is not stored as a separate field on the wire. Parts of it live in the
632
- envelope header; `scope` comes from the local `e2ee.encryption.scope`
633
- config. Keep `scope` stable and avoid full gateway URLs, otherwise old encrypted
634
- history may become unreadable after host or proxy changes.
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 encryption config | Remote plaintext accepted? | Local writes sent as |
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
- | Private reader/writer | `encrypted-only` (default) | `encrypt` (default) | `scope` + `writeKey` | No | encrypted |
644
- | Mixed private reader/writer | `allow-plaintext` | `encrypt` | `scope` + `writeKey` | Yes | encrypted |
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
- | Encrypted reader without a write key | `encrypted-only` | `plaintext` | `scope` + `readKeys` | No | plaintext if local writes are exported |
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 "read-only mode" in v1. The last row is
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
- encryption: {
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
- encryption: {
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
- encryption: {
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
- encryption: {
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
- - `writePolicy: "encrypt"` requires `e2ee.encryption.scope` and
778
- `e2ee.encryption.writeKey`.
779
- - Any client that needs to decrypt encrypted payloads must provide
780
- `e2ee.encryption.scope` plus at least one read key. If
781
- `readKeys` is omitted, the current `writeKey` is also used as the only read
782
- key.
783
- - `payloadProtection` is a deprecated alias for `e2ee`. Passing both is an
784
- error.
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
- - Writers always encrypt with the current `writeKey`. Keep old keys in
800
- `readKeys` until retained encrypted history and encrypted snapshots no longer
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
- - Encryption adds envelope overhead. The transport enforces the 256 KB batch
803
- cap against the encoded on-wire bytes, so a batch near the limit may split
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 envelope `keyId` does not exist in the configured `readKeys`. |
819
- | `decrypt_failed` | AES-GCM authentication failed. This usually means the wrong key, wrong scope, or tampered/corrupted bytes. |
820
- | `invalid_envelope` | The payload was not a supported streams-crdt encrypted envelope. |
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
- E2EE does **not** hide stream URLs, offsets, payload sizes, timing
828
- or auth metadata, and it does not detect a malicious server rolling back or
829
- forking history.
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: createStreamUrl({
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: createStreamUrl({
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: createStreamUrl({
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` — local error class thrown by low-level E2EE helpers before
1103
- conversion into `TransportError`
1104
- - `E2eeOptions`, `E2eeKey`, `E2eeScope`, `E2eeEncryptionOptions`
1105
- — E2EE configuration types
1106
- - `PayloadProtectionError`, `PayloadProtectionOptions`, `PayloadProtectionKey`,
1107
- `PayloadProtectionScope` — deprecated compatibility aliases
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
- - `createStreamUrl(...)` — build stream URL from bucket/stream IDs
1118
- - `isValidBucketId(...)`, `isValidRillId(...)` — ID validation helpers
1432
+ - `isValidRillId(...)` — stream ID validation helper
1119
1433
 
1120
1434
  ### Loro Entry Point
1121
1435