@loro-dev/streams-crdt 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -208,59 +208,51 @@ const MAGIC = new Uint8Array([
208
208
  67,
209
209
  69
210
210
  ]);
211
- const ENVELOPE_VERSION = 1;
212
- const SUITE_AES_256_GCM = 1;
211
+ const PROVIDER_ENVELOPE_VERSION = 2;
212
+ const PAYLOAD_PROTECTION_PROTOCOL = "loro-streams-crdt-payload-protection";
213
213
  const UPDATE_BATCH_KIND = 1;
214
214
  const SNAPSHOT_KIND = 2;
215
- const AES_GCM_NONCE_BYTES = 12;
216
- const HEADER_BYTES = 10;
217
- const AES_GCM_TAG_BITS = 128;
218
- const RAW_AES_256_KEY_BYTES = 32;
219
- const textEncoder$1 = new TextEncoder();
220
- const textDecoder = new TextDecoder();
215
+ const PROVIDER_PREFIX_BYTES = 10;
216
+ const MAX_PROVIDER_HEADER_BYTES = 512;
217
+ const MAX_PROVIDER_SEAL_OVERHEAD_BYTES = 4 * 1024;
218
+ const providerAadDomain = new TextEncoder().encode(`${PAYLOAD_PROTECTION_PROTOCOL}/v2\0`);
221
219
  var PayloadProtectionError = class extends Error {
222
220
  reason;
223
- keyId;
224
- constructor(reason, message, options = {}) {
225
- super(message, options.cause == null ? void 0 : { cause: options.cause });
221
+ constructor(reason, message = payloadProtectionFailureMessage(reason)) {
222
+ super(message);
226
223
  this.name = "PayloadProtectionError";
227
224
  this.reason = reason;
228
- this.keyId = options.keyId;
225
+ }
226
+ };
227
+ var ProviderSealContractError = class extends PayloadProtectionError {
228
+ constructor(message) {
229
+ super("encrypt_failed", message);
230
+ this.name = "ProviderSealContractError";
229
231
  }
230
232
  };
231
233
  var PayloadProtectionRuntime = class {
232
234
  readPolicy;
233
235
  writePolicy;
234
- scope;
235
- writeKey;
236
- readKeys;
237
- importedWriteKey;
238
- importedReadKeys = /* @__PURE__ */ new Map();
236
+ provider;
239
237
  constructor(options) {
240
238
  this.readPolicy = options.readPolicy;
241
239
  this.writePolicy = options.writePolicy;
242
- this.scope = options.scope;
243
- this.writeKey = options.writeKey;
244
- this.readKeys = options.readKeys;
240
+ this.provider = options.provider;
245
241
  }
246
242
  async encodeUpdateItems(clearUpdates) {
247
243
  if (this.writePolicy === "plaintext") return clearUpdates;
248
- return [await this.encryptPayload("update_batch", encodeItems(clearUpdates))];
244
+ return [await this.sealPayload("update_batch", encodeItems(clearUpdates))];
249
245
  }
250
246
  measureEncodedUpdateItemsByteLength(clearUpdates) {
251
247
  if (this.writePolicy === "plaintext") return encodedItemsByteLength(clearUpdates);
252
- const writeKey = this.writeKey;
253
- if (writeKey == null || this.scope == null) throw new PayloadProtectionError("encrypt_failed", "E2EE write encryption requires encryption.writeKey and encryption.scope");
254
- const clearBatchBytes = encodedItemsByteLength(clearUpdates);
255
- const keyIdBytes = textEncoder$1.encode(writeKey.id);
256
- if (keyIdBytes.byteLength === 0 || keyIdBytes.byteLength > 255) throw new PayloadProtectionError("encrypt_failed", "E2EE key id must be 1..255 UTF-8 bytes", { keyId: writeKey.id });
257
- return 4 + (HEADER_BYTES + keyIdBytes.byteLength + AES_GCM_NONCE_BYTES + clearBatchBytes + AES_GCM_TAG_BITS / 8);
248
+ if (this.provider == null) throw new PayloadProtectionError("encrypt_failed", "provider-backed E2EE writes require a provider");
249
+ return 4 + PROVIDER_PREFIX_BYTES + encodedItemsByteLength(clearUpdates) + this.provider.maxSealOverheadBytes;
258
250
  }
259
251
  async decodeUpdateItems(wireItems) {
260
252
  const out = [];
261
253
  for (const item of wireItems) {
262
254
  if (isEncryptedEnvelope(item)) {
263
- const clearBatch = await this.decryptPayload("update_batch", item);
255
+ const clearBatch = await this.openPayload("update_batch", item);
264
256
  out.push(...decodeItems(clearBatch));
265
257
  continue;
266
258
  }
@@ -271,75 +263,76 @@ var PayloadProtectionRuntime = class {
271
263
  }
272
264
  async encodeSnapshot(snapshot) {
273
265
  if (this.writePolicy === "plaintext") return snapshot;
274
- return await this.encryptPayload("snapshot", snapshot);
266
+ return await this.sealPayload("snapshot", snapshot);
275
267
  }
276
268
  async decodeSnapshot(snapshot) {
277
- if (isEncryptedEnvelope(snapshot)) return await this.decryptPayload("snapshot", snapshot);
269
+ if (isEncryptedEnvelope(snapshot)) return await this.openPayload("snapshot", snapshot);
278
270
  this.assertPlaintextAllowed("snapshot");
279
271
  return snapshot;
280
272
  }
281
- async encryptPayload(kind, plaintext) {
282
- const writeKey = this.writeKey;
283
- if (writeKey == null || this.scope == null) throw new PayloadProtectionError("encrypt_failed", "E2EE write encryption requires encryption.writeKey and encryption.scope");
273
+ async sealPayload(kind, plaintext) {
274
+ return await this.sealProviderPayload(kind, plaintext);
275
+ }
276
+ async openPayload(expectedKind, envelope) {
277
+ const version = envelope[4];
278
+ if (version === PROVIDER_ENVELOPE_VERSION) return await this.openProviderPayload(expectedKind, envelope);
279
+ throw new PayloadProtectionError("invalid_envelope", `unsupported streams-crdt protected payload envelope version '${String(version)}'`);
280
+ }
281
+ async sealProviderPayload(kind, plaintext) {
282
+ const provider = this.provider;
283
+ if (provider == null) throw new PayloadProtectionError("encrypt_failed", "provider-backed E2EE writes require a provider");
284
+ const context = createProviderContext(kind);
285
+ let authenticatedHeader;
286
+ let additionalDataCalls = 0;
287
+ let result;
284
288
  try {
285
- const key = await (this.importedWriteKey ??= importAesGcmKey(writeKey.key));
286
- const nonce = createNonce();
287
- return concatBytes(createEnvelopeHeader(kind, writeKey.id, nonce), new Uint8Array(await getSubtleCrypto().encrypt({
288
- name: "AES-GCM",
289
- iv: toArrayBuffer(nonce),
290
- tagLength: AES_GCM_TAG_BITS,
291
- additionalData: toArrayBuffer(createAad({
292
- kind,
293
- keyId: writeKey.id,
294
- scope: this.scope
295
- }))
296
- }, key, toArrayBuffer(plaintext))));
297
- } catch (error) {
298
- if (error instanceof PayloadProtectionError) throw error;
299
- throw new PayloadProtectionError("encrypt_failed", "failed to encrypt streams-crdt payload", {
300
- keyId: writeKey.id,
301
- cause: error
289
+ result = await provider.seal({
290
+ plaintext,
291
+ context,
292
+ additionalData: (header) => {
293
+ additionalDataCalls += 1;
294
+ if (additionalDataCalls !== 1) throw new ProviderSealContractError("payload protection provider must request additionalData exactly once");
295
+ authenticatedHeader = normalizeProviderHeader(header).slice();
296
+ return createProviderAad(createProviderEnvelopeHeader(kind, authenticatedHeader));
297
+ }
302
298
  });
299
+ } catch (error) {
300
+ if (error instanceof ProviderSealContractError) throw error;
301
+ const reason = error instanceof PayloadProtectionError ? error.reason : "encrypt_failed";
302
+ throw new PayloadProtectionError(reason, payloadProtectionFailureMessage(reason));
303
303
  }
304
- }
305
- async decryptPayload(expectedKind, envelope) {
306
- const parsed = parseEnvelope(envelope);
307
- if (parsed.kind !== expectedKind) throw new PayloadProtectionError("wrong_payload_kind", `expected ${expectedKind} payload, got ${parsed.kind}`, { keyId: parsed.keyId });
308
- if (this.scope == null) throw new PayloadProtectionError("missing_read_key", "E2EE read decryption requires encryption.scope", { keyId: parsed.keyId });
309
- const key = await this.getReadKey(parsed.keyId);
310
304
  try {
311
- return new Uint8Array(await getSubtleCrypto().decrypt({
312
- name: "AES-GCM",
313
- iv: toArrayBuffer(parsed.nonce),
314
- tagLength: AES_GCM_TAG_BITS,
315
- additionalData: toArrayBuffer(createAad({
316
- kind: parsed.kind,
317
- keyId: parsed.keyId,
318
- scope: this.scope
319
- }))
320
- }, key, toArrayBuffer(parsed.ciphertext)));
305
+ const returnedHeader = normalizeProviderHeader(result.header);
306
+ if (additionalDataCalls !== 1) throw new ProviderSealContractError("payload protection provider must request additionalData exactly once");
307
+ if (authenticatedHeader == null || !equalBytes(authenticatedHeader, returnedHeader)) throw new ProviderSealContractError("payload protection provider must authenticate the exact header it returns");
308
+ if (!(result.sealed instanceof Uint8Array) || result.sealed.byteLength === 0) throw new ProviderSealContractError("payload protection provider returned empty or invalid sealed bytes");
309
+ if (returnedHeader.byteLength + result.sealed.byteLength - plaintext.byteLength > provider.maxSealOverheadBytes) throw new ProviderSealContractError("payload protection provider exceeded maxSealOverheadBytes");
310
+ return concatBytes(createProviderEnvelopeHeader(kind, returnedHeader), result.sealed);
321
311
  } catch (error) {
322
- throw new PayloadProtectionError("decrypt_failed", "failed to decrypt streams-crdt payload", {
323
- keyId: parsed.keyId,
324
- cause: error
325
- });
312
+ if (error instanceof ProviderSealContractError) throw error;
313
+ const reason = error instanceof PayloadProtectionError ? error.reason : "encrypt_failed";
314
+ throw new PayloadProtectionError(reason, payloadProtectionFailureMessage(reason));
326
315
  }
327
316
  }
328
- async getReadKey(keyId) {
329
- const cached = this.importedReadKeys.get(keyId);
330
- if (cached != null) return await cached;
331
- const imported = importAesGcmKey((await this.resolveReadKey(keyId)).key);
332
- this.importedReadKeys.set(keyId, imported);
333
- return await imported;
334
- }
335
- async resolveReadKey(keyId) {
336
- for (const key of await this.resolveReadKeys()) if (key.id === keyId) return key;
337
- throw new PayloadProtectionError("missing_read_key", `missing read key for protected streams-crdt payload '${keyId}'`, { keyId });
338
- }
339
- async resolveReadKeys() {
340
- const readKeys = typeof this.readKeys === "function" ? await this.readKeys() : this.readKeys;
341
- if (readKeys != null) return readKeys;
342
- return this.writeKey == null ? [] : [this.writeKey];
317
+ async openProviderPayload(expectedKind, envelope) {
318
+ const parsed = parseProviderEnvelope(envelope);
319
+ if (parsed.kind !== expectedKind) throw new PayloadProtectionError("wrong_payload_kind", `expected ${expectedKind} payload, got ${parsed.kind}`);
320
+ const provider = this.provider;
321
+ if (provider == null) throw new PayloadProtectionError("missing_read_key", "provider-backed E2EE reads require a provider");
322
+ const context = createProviderContext(parsed.kind);
323
+ try {
324
+ const plaintext = await provider.open({
325
+ sealed: parsed.sealed,
326
+ header: parsed.header,
327
+ context,
328
+ additionalData: createProviderAad(parsed.authenticatedHeader)
329
+ });
330
+ if (!(plaintext instanceof Uint8Array)) throw new PayloadProtectionError("decrypt_failed", "payload protection provider returned invalid plaintext bytes");
331
+ return plaintext;
332
+ } catch (error) {
333
+ const reason = error instanceof PayloadProtectionError ? error.reason : "decrypt_failed";
334
+ throw new PayloadProtectionError(reason, payloadProtectionFailureMessage(reason));
335
+ }
343
336
  }
344
337
  assertPlaintextAllowed(label) {
345
338
  if (this.readPolicy === "allow-plaintext") return;
@@ -352,62 +345,74 @@ function normalizePayloadProtection(options, label = "payloadProtection") {
352
345
  const writePolicy = options.writePolicy ?? "encrypt";
353
346
  if (readPolicy !== "encrypted-only" && readPolicy !== "allow-plaintext") throw new Error(`${label}.readPolicy must be encrypted-only or allow-plaintext`);
354
347
  if (writePolicy !== "encrypt" && writePolicy !== "plaintext") throw new Error(`${label}.writePolicy must be encrypt or plaintext`);
355
- const encryption = options.encryption;
356
- if (encryption != null && encryption.scope == null) throw new Error(`${label}.encryption.scope is required when encryption is configured`);
357
- if (writePolicy === "encrypt" && encryption?.writeKey == null) throw new Error(`${label}.encryption.writeKey is required when writePolicy is encrypt`);
358
- if (writePolicy === "encrypt" && encryption?.scope == null) throw new Error(`${label}.encryption.scope is required when writePolicy is encrypt`);
359
- validateKey(encryption?.writeKey, `${label}.encryption.writeKey`);
360
- const readKeys = normalizeReadKeys(encryption?.readKeys, `${label}.encryption.readKeys`);
348
+ const provider = options.provider;
349
+ if (provider != null && (typeof provider.seal !== "function" || typeof provider.open !== "function" || !Number.isSafeInteger(provider.maxSealOverheadBytes) || provider.maxSealOverheadBytes < 0 || provider.maxSealOverheadBytes > MAX_PROVIDER_SEAL_OVERHEAD_BYTES)) throw new Error(`${label}.provider must implement seal()/open() and declare maxSealOverheadBytes as a safe integer from 0 through ${MAX_PROVIDER_SEAL_OVERHEAD_BYTES}`);
350
+ if (writePolicy === "encrypt" && provider == null) throw new Error(`${label}.provider is required when writePolicy is encrypt`);
361
351
  return new PayloadProtectionRuntime({
362
352
  readPolicy,
363
353
  writePolicy,
364
- scope: encryption?.scope,
365
- writeKey: encryption?.writeKey,
366
- readKeys
354
+ provider
367
355
  });
368
356
  }
369
357
  function isPayloadProtectionError(error) {
370
358
  return error instanceof PayloadProtectionError;
371
359
  }
360
+ function payloadProtectionFailureMessage(reason) {
361
+ switch (reason) {
362
+ case "plaintext_forbidden": return "plaintext payload rejected by protected-read policy";
363
+ case "missing_read_key": return "protected payload key is unavailable";
364
+ case "decrypt_failed": return "protected payload authentication failed";
365
+ case "invalid_envelope": return "invalid protected payload envelope";
366
+ case "wrong_payload_kind": return "protected payload kind mismatch";
367
+ case "encrypt_failed": return "protected payload sealing failed";
368
+ }
369
+ }
372
370
  function isEncryptedEnvelope(payload) {
373
371
  return payload.byteLength >= MAGIC.byteLength && MAGIC.every((byte, index) => payload[index] === byte);
374
372
  }
375
- function createEnvelopeHeader(kind, keyId, nonce) {
376
- const keyIdBytes = textEncoder$1.encode(keyId);
377
- if (keyIdBytes.byteLength === 0 || keyIdBytes.byteLength > 255) throw new PayloadProtectionError("encrypt_failed", "E2EE key id must be 1..255 UTF-8 bytes", { keyId });
378
- if (nonce.byteLength !== AES_GCM_NONCE_BYTES) throw new PayloadProtectionError("encrypt_failed", `AES-GCM nonce must be ${AES_GCM_NONCE_BYTES} bytes`, { keyId });
379
- const header = new Uint8Array(HEADER_BYTES + keyIdBytes.byteLength + nonce.byteLength);
373
+ function createProviderContext(kind) {
374
+ return {
375
+ protocol: PAYLOAD_PROTECTION_PROTOCOL,
376
+ version: PROVIDER_ENVELOPE_VERSION,
377
+ kind
378
+ };
379
+ }
380
+ function createProviderEnvelopeHeader(kind, providerHeader) {
381
+ const normalizedHeader = normalizeProviderHeader(providerHeader);
382
+ const header = new Uint8Array(PROVIDER_PREFIX_BYTES + normalizedHeader.byteLength);
383
+ const view = new DataView(header.buffer);
380
384
  header.set(MAGIC, 0);
381
- header[4] = ENVELOPE_VERSION;
385
+ header[4] = PROVIDER_ENVELOPE_VERSION;
382
386
  header[5] = encodeKind(kind);
383
- header[6] = SUITE_AES_256_GCM;
384
- header[7] = keyIdBytes.byteLength;
385
- header[8] = nonce.byteLength;
387
+ view.setUint16(6, normalizedHeader.byteLength, false);
388
+ header[8] = 0;
386
389
  header[9] = 0;
387
- header.set(keyIdBytes, HEADER_BYTES);
388
- header.set(nonce, HEADER_BYTES + keyIdBytes.byteLength);
390
+ header.set(normalizedHeader, PROVIDER_PREFIX_BYTES);
389
391
  return header;
390
392
  }
391
- function parseEnvelope(payload) {
392
- if (!isEncryptedEnvelope(payload) || payload.byteLength < HEADER_BYTES) throw new PayloadProtectionError("invalid_envelope", "invalid streams-crdt encrypted payload envelope");
393
- const version = payload[4];
394
- const suite = payload[6];
395
- const keyIdLen = payload[7] ?? 0;
396
- const nonceLen = payload[8] ?? 0;
397
- const reserved = payload[9];
398
- if (version !== ENVELOPE_VERSION || suite !== SUITE_AES_256_GCM || nonceLen !== AES_GCM_NONCE_BYTES || reserved !== 0) throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt encrypted payload envelope");
399
- const kind = decodeKind(payload[5]);
400
- const keyIdStart = HEADER_BYTES;
401
- const nonceStart = keyIdStart + keyIdLen;
402
- const ciphertextStart = nonceStart + nonceLen;
403
- if (keyIdLen === 0 || payload.byteLength <= ciphertextStart) throw new PayloadProtectionError("invalid_envelope", "truncated streams-crdt encrypted payload envelope");
393
+ function parseProviderEnvelope(payload) {
394
+ if (!isEncryptedEnvelope(payload) || payload.byteLength < PROVIDER_PREFIX_BYTES) throw new PayloadProtectionError("invalid_envelope", "invalid streams-crdt provider payload envelope");
395
+ const providerHeaderLength = new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint16(6, false);
396
+ if (payload[4] !== PROVIDER_ENVELOPE_VERSION || providerHeaderLength === 0 || providerHeaderLength > MAX_PROVIDER_HEADER_BYTES || payload[8] !== 0 || payload[9] !== 0) throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt provider payload envelope");
397
+ const headerEnd = PROVIDER_PREFIX_BYTES + providerHeaderLength;
398
+ if (payload.byteLength <= headerEnd) throw new PayloadProtectionError("invalid_envelope", "truncated streams-crdt provider payload envelope");
404
399
  return {
405
- kind,
406
- keyId: textDecoder.decode(payload.slice(keyIdStart, nonceStart)),
407
- nonce: payload.slice(nonceStart, ciphertextStart),
408
- ciphertext: payload.slice(ciphertextStart)
400
+ kind: decodeKind(payload[5]),
401
+ header: payload.slice(PROVIDER_PREFIX_BYTES, headerEnd),
402
+ authenticatedHeader: payload.slice(0, headerEnd),
403
+ sealed: payload.slice(headerEnd)
409
404
  };
410
405
  }
406
+ function normalizeProviderHeader(header) {
407
+ if (!(header instanceof Uint8Array) || header.byteLength === 0 || header.byteLength > MAX_PROVIDER_HEADER_BYTES) throw new ProviderSealContractError(`payload protection provider header must be 1..${MAX_PROVIDER_HEADER_BYTES} bytes`);
408
+ return header;
409
+ }
410
+ function equalBytes(left, right) {
411
+ return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]);
412
+ }
413
+ function createProviderAad(authenticatedHeader) {
414
+ return concatBytes(providerAadDomain, authenticatedHeader);
415
+ }
411
416
  function encodeKind(kind) {
412
417
  return kind === "update_batch" ? UPDATE_BATCH_KIND : SNAPSHOT_KIND;
413
418
  }
@@ -416,74 +421,18 @@ function decodeKind(encoded) {
416
421
  if (encoded === SNAPSHOT_KIND) return "snapshot";
417
422
  throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt encrypted payload kind");
418
423
  }
419
- function createNonce() {
420
- const nonce = new Uint8Array(AES_GCM_NONCE_BYTES);
421
- getCrypto().getRandomValues(nonce);
422
- return nonce;
423
- }
424
- function createAad(input) {
425
- return textEncoder$1.encode(JSON.stringify({
426
- keyId: input.keyId,
427
- kind: input.kind,
428
- protocol: "loro-streams-crdt-payload-protection",
429
- scope: normalizeScope(input.scope),
430
- suite: "aes_256_gcm",
431
- version: ENVELOPE_VERSION
432
- }));
433
- }
434
- function normalizeScope(scope) {
435
- return typeof scope === "string" ? scope : `bucket:${scope.bucketId}\nstream:${scope.streamId}`;
436
- }
437
- function concatBytes(left, right) {
438
- const out = new Uint8Array(left.byteLength + right.byteLength);
439
- out.set(left);
440
- out.set(right, left.byteLength);
424
+ function concatBytes(...parts) {
425
+ const out = new Uint8Array(parts.reduce((length, part) => length + part.byteLength, 0));
426
+ let cursor = 0;
427
+ for (const part of parts) {
428
+ out.set(part, cursor);
429
+ cursor += part.byteLength;
430
+ }
441
431
  return out;
442
432
  }
443
433
  function encodedItemsByteLength(items) {
444
434
  return items.reduce((sum, item) => sum + 4 + item.byteLength, 0);
445
435
  }
446
- async function importAesGcmKey(key) {
447
- if (isCryptoKey(key)) return key;
448
- validateRawKey(key);
449
- return await getSubtleCrypto().importKey("raw", toArrayBuffer(key), "AES-GCM", false, ["encrypt", "decrypt"]);
450
- }
451
- function toArrayBuffer(bytes) {
452
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
453
- }
454
- function validateKey(key, label) {
455
- if (key == null) return;
456
- if (key.id.trim().length === 0) throw new Error(`${label}.id must be non-empty`);
457
- if (key.key instanceof Uint8Array) validateRawKey(key.key);
458
- }
459
- function normalizeReadKeys(readKeys, label) {
460
- if (readKeys == null) return;
461
- if (typeof readKeys === "function") return async () => {
462
- const resolved = await readKeys();
463
- validateKeys(resolved, label);
464
- return resolved;
465
- };
466
- validateKeys(readKeys, label);
467
- return readKeys;
468
- }
469
- function validateKeys(keys, label) {
470
- for (const [index, key] of keys.entries()) validateKey(key, `${label}[${index}]`);
471
- }
472
- function isCryptoKey(key) {
473
- return typeof CryptoKey !== "undefined" && key instanceof CryptoKey;
474
- }
475
- function validateRawKey(key) {
476
- if (key.byteLength !== RAW_AES_256_KEY_BYTES) throw new Error(`E2EE raw AES-GCM keys must be ${RAW_AES_256_KEY_BYTES} bytes`);
477
- }
478
- function getCrypto() {
479
- if (globalThis.crypto == null) throw new PayloadProtectionError("encrypt_failed", "Web Crypto is unavailable in this runtime");
480
- return globalThis.crypto;
481
- }
482
- function getSubtleCrypto() {
483
- const subtle = getCrypto().subtle;
484
- if (subtle == null) throw new PayloadProtectionError("encrypt_failed", "SubtleCrypto is unavailable in this runtime");
485
- return subtle;
486
- }
487
436
  //#endregion
488
437
  //#region src/transport-internal.ts
489
438
  function createProducerId() {
@@ -844,8 +793,7 @@ function toTransportError(error) {
844
793
  code: "payload_protection_error",
845
794
  retryable: false,
846
795
  reason: error.reason,
847
- keyId: error.keyId,
848
- message: error.message
796
+ message: payloadProtectionFailureMessage(error.reason)
849
797
  };
850
798
  if (error instanceof TypeError) return {
851
799
  code: "network_error",
@@ -2118,11 +2066,32 @@ function createInitialRemoteCursor(input) {
2118
2066
  }
2119
2067
  //#endregion
2120
2068
  //#region src/transport-cursor.ts
2069
+ const MAX_SPANS_IN_MESSAGE = 8;
2070
+ /**
2071
+ * Renders a bounded summary of unresolved spans for the error message, so a
2072
+ * caller's error log names the author(s) whose ops are missing from the
2073
+ * stream without the caller having to unwrap `error.unresolved` itself.
2074
+ */
2075
+ function formatUnresolvedSpans(unresolved) {
2076
+ if (unresolved.length === 0) return "";
2077
+ const shown = unresolved.slice(0, MAX_SPANS_IN_MESSAGE).map((span) => `${span.peer}@[${span.start}..${span.end})`).join(", ");
2078
+ const suffix = unresolved.length > MAX_SPANS_IN_MESSAGE ? `, +${unresolved.length - MAX_SPANS_IN_MESSAGE} more` : "";
2079
+ return ` (missing deps for ${unresolved.length} span(s): ${shown}${suffix})`;
2080
+ }
2121
2081
  var CrdtApplyIncompleteError = class extends Error {
2122
2082
  unresolved;
2123
- constructor(unresolved) {
2124
- super("CRDT apply incomplete: unresolved remote updates remain");
2083
+ /**
2084
+ * The volatile server version at the moment apply failed: everything the
2085
+ * server delivered that the local doc actually applied (success spans
2086
+ * only — pending spans never advance it). `exportUpdates(appliedVersion)`
2087
+ * is therefore exactly "the ops this replica holds that the server does
2088
+ * not", which is what the join-time salvage append publishes.
2089
+ */
2090
+ appliedVersion;
2091
+ constructor(unresolved, appliedVersion) {
2092
+ super(`CRDT apply incomplete: unresolved remote updates remain${formatUnresolvedSpans(unresolved)}`);
2125
2093
  this.unresolved = unresolved;
2094
+ this.appliedVersion = appliedVersion;
2126
2095
  }
2127
2096
  };
2128
2097
  function isApplyOutcome(value) {
@@ -2150,6 +2119,22 @@ var TransportCursorManager = class {
2150
2119
  isCursorComplete(cursor) {
2151
2120
  return !this.incompleteCursors.has(cursor);
2152
2121
  }
2122
+ /**
2123
+ * Marks `cursor` incomplete without an apply.
2124
+ *
2125
+ * The `CrdtApplyIncompleteError` throw path discards its candidate cursor
2126
+ * BEFORE the usual WeakMap marking, so a caller's retained cursor keeps
2127
+ * whatever completeness it had. For a caught-up live room the first
2128
+ * poisoned event typically arrives with `up_to_date=true`, so the retained
2129
+ * cursor still reads "complete" for the whole wedge — leaving the degraded
2130
+ * local flush unengaged and the snapshot-upload clean gate open, exactly
2131
+ * the two consumers keyed on this marking. The read loop calls this when
2132
+ * it catches the error so the room's incomplete state is visible on the
2133
+ * cursor it actually holds.
2134
+ */
2135
+ markCursorIncomplete(cursor, unresolved) {
2136
+ this.incompleteCursors.set(cursor, unresolved);
2137
+ }
2153
2138
  async applyBootstrapPayload(cursor, snapshot, updates, nextOffset, upToDate = false) {
2154
2139
  let nextVersion = cursor.serverLowerBoundVersion;
2155
2140
  let complete = true;
@@ -2223,7 +2208,7 @@ var TransportCursorManager = class {
2223
2208
  }
2224
2209
  async finalizeCursor(cursor, source, complete, unresolved, upToDate) {
2225
2210
  if (!complete) {
2226
- if (upToDate) throw new CrdtApplyIncompleteError(unresolved);
2211
+ if (upToDate) throw new CrdtApplyIncompleteError(unresolved, cursor.serverLowerBoundVersion);
2227
2212
  this.incompleteCursors.set(cursor, unresolved);
2228
2213
  return cursor;
2229
2214
  }
@@ -2378,6 +2363,7 @@ function createJoinState(cursor, liveMode = "sse") {
2378
2363
  pendingLocal: new Deque(),
2379
2364
  flushingLocal: false,
2380
2365
  retryAttempt: 0,
2366
+ incompleteRecoveryAttempt: 0,
2381
2367
  retrySleepController: new AbortController(),
2382
2368
  readLoopRunning: false,
2383
2369
  readErrorRetryAttempt: 0,
@@ -2480,11 +2466,9 @@ var StreamsCrdt = class StreamsCrdt {
2480
2466
  this.debug = options.debug ?? false;
2481
2467
  this.beforeRemoteCursorSave = composeBeforeRemoteCursorSaveHooks(options.beforeRemoteCursorSave, ...options.beforeRemoteCursorSaveHooks ?? []);
2482
2468
  const snapshotCodec = normalizeSnapshotCodec(options.snapshotCodec);
2483
- if (options.e2ee != null && options.payloadProtection != null) throw new Error("Specify only one of e2ee or payloadProtection");
2484
- const payloadProtectionOption = options.e2ee ?? options.payloadProtection;
2485
- const payloadProtectionLabel = options.e2ee != null ? "e2ee" : "payloadProtection";
2486
- const payloadProtection = normalizePayloadProtection(payloadProtectionOption, payloadProtectionLabel);
2487
- if (payloadProtection?.writePolicy === "plaintext" && options.snapshotUpload != null && options.snapshotUpload.enabled !== false) throw new Error(`snapshotUpload is not supported when ${payloadProtectionLabel}.writePolicy is plaintext`);
2469
+ if (options.payloadProtectionRequired === true && options.e2ee == null) throw new Error("payloadProtectionRequired is true but this room has no e2ee config");
2470
+ const payloadProtection = normalizePayloadProtection(options.e2ee, "e2ee");
2471
+ if (payloadProtection?.writePolicy === "plaintext" && options.snapshotUpload != null && options.snapshotUpload.enabled !== false) throw new Error("snapshotUpload is not supported when e2ee.writePolicy is plaintext");
2488
2472
  const snapshotUploadConfig = normalizeSnapshotUploadConfig(options.snapshotUpload);
2489
2473
  this.decodeSnapshot = this.composeSnapshotDecoder(payloadProtection, snapshotCodec?.decompress);
2490
2474
  this.encodeSnapshot = this.composeSnapshotEncoder(payloadProtection, snapshotCodec?.compress);
@@ -2963,7 +2947,13 @@ var StreamsCrdt = class StreamsCrdt {
2963
2947
  async startJoin(state) {
2964
2948
  try {
2965
2949
  state.requestController ??= new AbortController();
2966
- const joined = await this.performInitialJoinSync(state.liveMode, state.requestController?.signal);
2950
+ let joined;
2951
+ try {
2952
+ joined = await this.performInitialJoinSync(state.liveMode, state.requestController?.signal);
2953
+ } catch (error) {
2954
+ if (!(error instanceof CrdtApplyIncompleteError) || state.closed || !await this.salvageLocalBacklogOnIncompleteInitialSync(state, error)) throw error;
2955
+ joined = await this.performInitialJoinSync(state.liveMode, state.requestController?.signal);
2956
+ }
2967
2957
  state.cursor = joined.cursor;
2968
2958
  state.streamCursor = joined.streamCursor;
2969
2959
  state.liveMode = joined.liveMode;
@@ -2995,6 +2985,65 @@ var StreamsCrdt = class StreamsCrdt {
2995
2985
  throw error;
2996
2986
  }
2997
2987
  }
2988
+ /**
2989
+ * Join-time salvage for a stream whose head cannot be fully applied
2990
+ * (apply_incomplete at `up_to_date`, even after bootstrap): before the
2991
+ * join surfaces the error, durably append everything this replica holds
2992
+ * that the server does not.
2993
+ *
2994
+ * Why this exists: the dual-author wedge is usually two-sided — the stream
2995
+ * is missing some author's ops, and a replica that HOLDS those ops (e.g.
2996
+ * received out-of-band over a local data plane) may itself be unable to
2997
+ * complete the join because of a second gap. The regular join-time export
2998
+ * runs only AFTER a successful initial sync, so without this salvage such
2999
+ * a replica can never publish the healing ops, and its own backlog from a
3000
+ * previous process run stays local for as long as the stream stays
3001
+ * poisoned.
3002
+ *
3003
+ * `error.appliedVersion` advances only from success spans, so
3004
+ * `exportUpdates(appliedVersion)` is exactly "ops this replica holds that
3005
+ * the server does not" — the failed initial sync already imported
3006
+ * everything the server DOES have, so the delta cannot re-append
3007
+ * server-known data. Cursor safety is untouched: the append is write-only
3008
+ * style (producer-acked, no read catch-up, no durable-cursor save), and
3009
+ * the retried initial sync re-derives its cursor from the server.
3010
+ *
3011
+ * Returns `true` when anything was durably appended; the caller then
3012
+ * retries the initial sync once. Best-effort: a salvage failure logs and
3013
+ * returns `false` so the original apply-incomplete error is what callers
3014
+ * observe.
3015
+ */
3016
+ async salvageLocalBacklogOnIncompleteInitialSync(state, error) {
3017
+ const appliedVersion = error.appliedVersion;
3018
+ if (appliedVersion == null) return false;
3019
+ try {
3020
+ const appended = await this.enqueueExclusive(async () => {
3021
+ let didAppend = false;
3022
+ const frozen = this.frozenLocalAppend;
3023
+ if (frozen != null) {
3024
+ const ack = await this.appendFrozenLocalBatchRemote(frozen);
3025
+ this.commitProducerAck(ack.producerAck);
3026
+ didAppend = true;
3027
+ }
3028
+ await this.adapter.flushLocalExports?.();
3029
+ const localBatch = this.exportUpdates(appliedVersion);
3030
+ if (localBatch.batch != null) {
3031
+ const appendedBatch = await this.appendLocalBatchRemote({
3032
+ ...state.cursor,
3033
+ serverLowerBoundVersion: appliedVersion
3034
+ }, localBatch.batch, void 0, "direct");
3035
+ this.commitProducerAck(appendedBatch.producerAck);
3036
+ didAppend = true;
3037
+ }
3038
+ return didAppend;
3039
+ });
3040
+ if (appended) this.logDebug("join salvage appended local backlog", { streamUrl: this.streamUrl });
3041
+ return appended;
3042
+ } catch (salvageError) {
3043
+ this.logError("join salvage append failed", salvageError, { streamUrl: this.streamUrl });
3044
+ return false;
3045
+ }
3046
+ }
2998
3047
  async syncOnce(operation, parentSignal) {
2999
3048
  let cursor = (await this.finalizeDirectLocalAppendIfNeeded(await this.resolveInitialRemoteState(operation, parentSignal))).cursor;
3000
3049
  const localBatch = this.exportUpdates(cursor.serverLowerBoundVersion);
@@ -3009,8 +3058,11 @@ var StreamsCrdt = class StreamsCrdt {
3009
3058
  let liveMode = preferredLiveMode;
3010
3059
  const exportedLocal = this.exportUpdates(localExportRefVersion);
3011
3060
  localExportRefVersion = exportedLocal.nextRefVersion;
3012
- if (exportedLocal.batch != null) {
3013
- const committed = await this.appendLocalBatchDurably(cursor, exportedLocal.batch, void 0, "direct", streamCursor);
3061
+ const exportedBatch = exportedLocal.batch;
3062
+ if (exportedBatch != null) {
3063
+ const appendCursor = cursor;
3064
+ const appendStreamCursor = streamCursor;
3065
+ const committed = await this.enqueueExclusive(() => this.appendLocalBatchDurably(appendCursor, exportedBatch, void 0, "direct", appendStreamCursor));
3014
3066
  cursor = committed.cursor;
3015
3067
  streamCursor = committed.streamCursor;
3016
3068
  }
@@ -3170,7 +3222,8 @@ var StreamsCrdt = class StreamsCrdt {
3170
3222
  };
3171
3223
  }
3172
3224
  }
3173
- async recoverLiveIncompleteRemoteApply(state) {
3225
+ async recoverLiveIncompleteRemoteApply(state, error) {
3226
+ this.cursorManager.markCursorIncomplete(state.cursor, error.unresolved);
3174
3227
  try {
3175
3228
  const bootstrapped = await this.bootstrapState({
3176
3229
  ...state.cursor,
@@ -3180,6 +3233,7 @@ var StreamsCrdt = class StreamsCrdt {
3180
3233
  state.cursor = bootstrapped.cursor;
3181
3234
  state.streamCursor = bootstrapped.streamCursor;
3182
3235
  state.retryAttempt = 0;
3236
+ state.incompleteRecoveryAttempt = 0;
3183
3237
  state.lastError = void 0;
3184
3238
  this.setReadSubStatus(state, "ok");
3185
3239
  if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
@@ -3191,7 +3245,10 @@ var StreamsCrdt = class StreamsCrdt {
3191
3245
  code: classified.code
3192
3246
  });
3193
3247
  state.lastError = toTransportError(error);
3248
+ if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
3194
3249
  if (classified.retriable && !isAuthOrProtocolError(error)) {
3250
+ state.incompleteRecoveryAttempt += 1;
3251
+ state.retryAttempt = Math.max(state.retryAttempt, state.incompleteRecoveryAttempt);
3195
3252
  this.setReadSubStatus(state, "reconnecting");
3196
3253
  return "retry";
3197
3254
  }
@@ -3667,7 +3724,7 @@ var StreamsCrdt = class StreamsCrdt {
3667
3724
  if (state.closed) return;
3668
3725
  if (error instanceof CrdtApplyIncompleteError) {
3669
3726
  if (capturedController?.signal.aborted && state.requestController === capturedController) state.requestController = new AbortController();
3670
- const recovery = await this.recoverLiveIncompleteRemoteApply(state);
3727
+ const recovery = await this.recoverLiveIncompleteRemoteApply(state, error);
3671
3728
  if (recovery === "recovered") continue;
3672
3729
  if (recovery === "terminal") return;
3673
3730
  if (await this.awaitReadRetryBackoff(state, error)) continue;
@@ -3701,7 +3758,7 @@ var StreamsCrdt = class StreamsCrdt {
3701
3758
  const flushError = abortReason instanceof Error ? abortReason : error;
3702
3759
  if (flushError instanceof CrdtApplyIncompleteError) {
3703
3760
  if (state.requestController === capturedController) state.requestController = new AbortController();
3704
- const recovery = await this.recoverLiveIncompleteRemoteApply(state);
3761
+ const recovery = await this.recoverLiveIncompleteRemoteApply(state, flushError);
3705
3762
  if (recovery === "recovered") continue;
3706
3763
  if (recovery === "terminal") return;
3707
3764
  if (await this.awaitReadRetryBackoff(state, flushError)) continue;
@@ -3796,10 +3853,15 @@ var StreamsCrdt = class StreamsCrdt {
3796
3853
  * `"error"` write status (with `lastWriteError` set) and then retries via
3797
3854
  * this flush's own backoff timer (`maybeScheduleWriteRetry`) — it does not
3798
3855
  * depend on a future local op to re-trigger the flush.
3856
+ *
3857
+ * While the cursor is incomplete (unresolved remote spans), the flush
3858
+ * degrades to an append-only mode instead of pausing: batches still reach
3859
+ * the server, but read progress, durable-cursor saves, and snapshot
3860
+ * scheduling stay untouched. See the inline comment in the incomplete
3861
+ * branch and specs/loro-pending.md "Local Append Rules".
3799
3862
  */
3800
3863
  async flushPendingLocal(state) {
3801
3864
  if (state.flushingLocal || state.closed) return;
3802
- if (!this.cursorManager.isCursorComplete(state.cursor)) return;
3803
3865
  this.clearWriteRetryTimer(state);
3804
3866
  state.flushingLocal = true;
3805
3867
  try {
@@ -3808,16 +3870,23 @@ var StreamsCrdt = class StreamsCrdt {
3808
3870
  if (drained == null) return;
3809
3871
  let attempts = 0;
3810
3872
  while (!state.closed) try {
3811
- state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch, drained.count, state.streamCursor));
3812
- const committedStreamCursor = this.consumeCommittedLocalStreamCursor();
3813
- if (committedStreamCursor != null) state.streamCursor = committedStreamCursor;
3814
- const committedCount = this.consumeCommittedPendingCount() ?? drained.count;
3873
+ let committedCount;
3874
+ if (this.cursorManager.isCursorComplete(state.cursor)) {
3875
+ state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch, drained.count, state.streamCursor));
3876
+ const committedStreamCursor = this.consumeCommittedLocalStreamCursor();
3877
+ if (committedStreamCursor != null) state.streamCursor = committedStreamCursor;
3878
+ committedCount = this.consumeCommittedPendingCount() ?? drained.count;
3879
+ this.snapshotManager.schedule(state);
3880
+ } else {
3881
+ const appended = await this.enqueueExclusive(async () => this.appendLocalBatchRemote(state.cursor, drained.batch, drained.count, "pending"));
3882
+ this.commitProducerAck(appended.producerAck);
3883
+ committedCount = appended.pendingCount ?? drained.count;
3884
+ }
3815
3885
  for (let i = 0; i < committedCount; i += 1) state.pendingLocal.popFront();
3816
3886
  this.notifySyncWaiters(state, committedCount);
3817
3887
  state.flushHeadBatchOnly = false;
3818
3888
  state.lastWriteError = void 0;
3819
3889
  this.setWriteSubStatus(state, "ok");
3820
- this.snapshotManager.schedule(state);
3821
3890
  break;
3822
3891
  } catch (error) {
3823
3892
  if (isPayloadTooLargeError(error) && drained.count > 1 && this.abandonPendingFrozenLocalAppend()) {
@@ -3911,12 +3980,15 @@ var StreamsCrdt = class StreamsCrdt {
3911
3980
  for (const waiter of state.syncWaiters.splice(0)) waiter.reject(error);
3912
3981
  }
3913
3982
  async saveRemoteCursor(cursor, source) {
3914
- return await persistRemoteCursor({
3983
+ const saved = await persistRemoteCursor({
3915
3984
  remoteCursorStore: this.remoteCursorStore,
3916
3985
  cursor,
3917
3986
  source,
3918
3987
  beforeRemoteCursorSave: this.beforeRemoteCursorSave
3919
3988
  });
3989
+ const state = this.joinState;
3990
+ if (state != null && !state.closed) state.incompleteRecoveryAttempt = 0;
3991
+ return saved;
3920
3992
  }
3921
3993
  setReadSubStatus(state, sub) {
3922
3994
  if (sub === "ok") state.readErrorRetryAttempt = 0;
@@ -3987,33 +4059,16 @@ var StreamsCrdt = class StreamsCrdt {
3987
4059
  //#region src/stream-id.ts
3988
4060
  const MAX_STREAM_ID_BYTES = 512;
3989
4061
  const textEncoder = new TextEncoder();
3990
- const DEFAULT_BASE_URL = "https://streams-api.loro.dev";
3991
4062
  function utf8ByteLength(value) {
3992
4063
  return textEncoder.encode(value).byteLength;
3993
4064
  }
3994
4065
  /**
3995
- * Validates a stream id accepted by `createStreamUrl()`.
4066
+ * Validates a Durable Streams stream id.
3996
4067
  */
3997
4068
  function isValidRillId(id) {
3998
- return id.length > 0 && utf8ByteLength(id) <= MAX_STREAM_ID_BYTES && !id.includes("/") && !id.includes("\0") && !id.includes("..");
3999
- }
4000
- /**
4001
- * Validates a bucket id accepted by `createStreamUrl()`.
4002
- */
4003
- function isValidBucketId(id) {
4004
- return isValidRillId(id);
4005
- }
4006
- /**
4007
- * Builds a Durable Streams HTTP URL for one `(bucketId, streamId)` pair.
4008
- */
4009
- function createStreamUrl(input) {
4010
- const baseUrl = (input.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/g, "");
4011
- if (!isValidBucketId(input.bucketId)) throw new Error(`invalid bucketId: ${JSON.stringify(input.bucketId)} (must be non-empty UTF-8, <= ${MAX_STREAM_ID_BYTES} bytes, and exclude "/", "\\0", "..")`);
4012
- if (!isValidRillId(input.streamId)) throw new Error(`invalid streamId: ${JSON.stringify(input.streamId)} (must be non-empty UTF-8, <= ${MAX_STREAM_ID_BYTES} bytes, and exclude "/", "\\0", "..")`);
4013
- const streamId = input.streamId;
4014
- return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
4069
+ return id.length > 0 && utf8ByteLength(id) <= MAX_STREAM_ID_BYTES && id !== "." && !id.includes("/") && !id.includes("\0") && !id.includes("..");
4015
4070
  }
4016
4071
  //#endregion
4017
- export { InMemoryRemoteCursorStore as a, EphemeralStreamCrdt as c, StreamsCrdt as i, LocalAppendFailedError as l, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t, PayloadProtectionError as u };
4072
+ export { createInitialRemoteCursor as a, PayloadProtectionError as c, IndexedDbRemoteCursorStore as i, StreamsCrdt as n, EphemeralStreamCrdt as o, InMemoryRemoteCursorStore as r, LocalAppendFailedError as s, isValidRillId as t };
4018
4073
 
4019
- //# sourceMappingURL=stream-id-CEp4zEQJ.js.map
4074
+ //# sourceMappingURL=stream-id-BygPTNvQ.js.map