@makerbi/remodex 2.0.1 → 2.3.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.
@@ -23,7 +23,7 @@ const {
23
23
  } = require("./secure-device-state");
24
24
 
25
25
  const PAIRING_QR_VERSION = 2;
26
- const SECURE_PROTOCOL_VERSION = 1;
26
+ const SECURE_PROTOCOL_VERSION = 2;
27
27
  const HANDSHAKE_TAG = "remodex-e2ee-v1";
28
28
  const HANDSHAKE_MODE_QR_BOOTSTRAP = "qr_bootstrap";
29
29
  const HANDSHAKE_MODE_TRUSTED_RECONNECT = "trusted_reconnect";
@@ -52,6 +52,9 @@ function createBridgeSecureTransport({
52
52
  let lastRelayedBridgeOutboundSeq = 0;
53
53
  let currentPairingExpiresAt = Date.now() + MAX_PAIRING_AGE_MS;
54
54
  let nextKeyEpoch = 1;
55
+ // Sequence numbers restart at 1 with each bridge process. Namespace them so
56
+ // a phone never mistakes new-process seq 1 for old-process seq 1.
57
+ const bridgeReplayEpoch = randomBytes(16).toString("hex");
55
58
  let nextBridgeOutboundSeq = 1;
56
59
  let outboundBufferBytes = 0;
57
60
  const outboundBuffer = [];
@@ -261,6 +264,7 @@ function createBridgeSecureTransport({
261
264
  macEphemeralPublicKey: pendingHandshake.macEphemeralPublicKey,
262
265
  serverNonce: serverNonce.toString("base64"),
263
266
  keyEpoch,
267
+ bridgeReplayEpoch,
264
268
  expiresAtForTranscript,
265
269
  macSignature,
266
270
  clientNonce: clientNonceBase64,
@@ -410,16 +414,49 @@ function createBridgeSecureTransport({
410
414
  }
411
415
 
412
416
  const lastAppliedBridgeOutboundSeq = Number(message.lastAppliedBridgeOutboundSeq) || 0;
413
- lastRelayedBridgeOutboundSeq = lastAppliedBridgeOutboundSeq;
414
- const missingEntries = replayableOutboundEntries(lastAppliedBridgeOutboundSeq, {
417
+ const phoneReplayEpoch = normalizeNonEmptyString(message.bridgeReplayEpoch);
418
+ let effectiveReplayCursor = lastAppliedBridgeOutboundSeq;
419
+ activeSession.isResumed = true;
420
+ if (phoneReplayEpoch !== bridgeReplayEpoch || lastAppliedBridgeOutboundSeq >= nextBridgeOutboundSeq) {
421
+ // Sequence numbers are process-local. A trusted phone can reconnect after
422
+ // the bridge restarted with any cursor from the previous process; the
423
+ // explicit epoch catches overlap that numeric comparison cannot detect.
424
+ effectiveReplayCursor = Math.max(0, activeSession.firstOutboundSeq - 1);
425
+ sendBufferedReplayResetMarker(
426
+ activeSession.sendWireMessage,
427
+ effectiveReplayCursor,
428
+ bridgeReplayEpoch
429
+ );
430
+ }
431
+ lastRelayedBridgeOutboundSeq = effectiveReplayCursor;
432
+ let missingEntries = replayableOutboundEntries(effectiveReplayCursor, {
415
433
  includeCurrentSessionEntries: true,
416
434
  });
417
- activeSession.isResumed = true;
435
+ const replayGap = bufferedReplayGapAfter(effectiveReplayCursor);
436
+ if (replayGap) {
437
+ // A partial historical tail is worse than no replay: item-scoped maps no
438
+ // longer exist after relaunch, so applying it can bind deltas/artifacts to
439
+ // unrelated rows. Tell the phone to advance past the discarded history;
440
+ // canonical thread history will rebuild it deterministically.
441
+ sendBufferedReplayGapMarker(activeSession.sendWireMessage, replayGap);
442
+ sendBufferedReplayCompleteMarker(activeSession.sendWireMessage);
443
+ missingEntries = missingEntries.filter((entry) => (
444
+ entry.bridgeOutboundSeq > replayGap.lastDiscardedBridgeOutboundSeq
445
+ ));
446
+ }
447
+ let replayedHistoricalBacklog = false;
418
448
  for (const entry of missingEntries) {
419
- if (!sendBufferedEntry(entry, activeSession.sendWireMessage)) {
420
- break;
449
+ const outboundEntry = replayTaggedEntryIfHistorical(entry);
450
+ if (!sendBufferedEntry(outboundEntry, activeSession.sendWireMessage)) {
451
+ return;
452
+ }
453
+ if (outboundEntry !== entry) {
454
+ replayedHistoricalBacklog = true;
421
455
  }
422
456
  }
457
+ if (replayedHistoricalBacklog) {
458
+ sendBufferedReplayCompleteMarker(activeSession.sendWireMessage);
459
+ }
423
460
  }
424
461
 
425
462
  function handleEncryptedEnvelope(message, sendControlMessage, onApplicationMessage) {
@@ -553,11 +590,169 @@ function createBridgeSecureTransport({
553
590
  return;
554
591
  }
555
592
 
556
- for (const entry of replayableOutboundEntries(lastRelayedBridgeOutboundSeq)) {
557
- if (!sendBufferedEntry(entry, activeSession.sendWireMessage)) {
558
- break;
593
+ let replayEntries = replayableOutboundEntries(lastRelayedBridgeOutboundSeq);
594
+ const replayGap = bufferedReplayGapAfter(lastRelayedBridgeOutboundSeq);
595
+ if (replayGap) {
596
+ sendBufferedReplayGapMarker(activeSession.sendWireMessage, replayGap);
597
+ sendBufferedReplayCompleteMarker(activeSession.sendWireMessage);
598
+ replayEntries = replayEntries.filter((entry) => (
599
+ entry.bridgeOutboundSeq > replayGap.lastDiscardedBridgeOutboundSeq
600
+ ));
601
+ }
602
+
603
+ let replayedHistoricalBacklog = false;
604
+ for (const entry of replayEntries) {
605
+ const outboundEntry = replayTaggedEntryIfHistorical(entry);
606
+ if (!sendBufferedEntry(outboundEntry, activeSession.sendWireMessage)) {
607
+ return;
608
+ }
609
+ if (outboundEntry !== entry) {
610
+ replayedHistoricalBacklog = true;
559
611
  }
560
612
  }
613
+ if (replayedHistoricalBacklog) {
614
+ sendBufferedReplayCompleteMarker(activeSession.sendWireMessage);
615
+ }
616
+ }
617
+
618
+ function bufferedReplayGapAfter(lastAppliedBridgeOutboundSeq) {
619
+ const firstUnappliedEntry = outboundBuffer.find((entry) => (
620
+ entry.bridgeOutboundSeq > lastAppliedBridgeOutboundSeq
621
+ ));
622
+ if (!firstUnappliedEntry
623
+ || firstUnappliedEntry.bridgeOutboundSeq <= lastAppliedBridgeOutboundSeq + 1) {
624
+ return null;
625
+ }
626
+
627
+ const lastAvailableBridgeOutboundSeq = outboundBuffer[outboundBuffer.length - 1]?.bridgeOutboundSeq
628
+ || firstUnappliedEntry.bridgeOutboundSeq;
629
+ const historicalBoundary = (activeSession?.firstOutboundSeq || firstUnappliedEntry.bridgeOutboundSeq) - 1;
630
+ const lastDiscardedBridgeOutboundSeq = firstUnappliedEntry.bridgeOutboundSeq <= historicalBoundary
631
+ ? historicalBoundary
632
+ : lastAvailableBridgeOutboundSeq;
633
+ return {
634
+ expectedBridgeOutboundSeq: lastAppliedBridgeOutboundSeq + 1,
635
+ firstAvailableBridgeOutboundSeq: firstUnappliedEntry.bridgeOutboundSeq,
636
+ lastDiscardedBridgeOutboundSeq: Math.max(lastAppliedBridgeOutboundSeq, lastDiscardedBridgeOutboundSeq),
637
+ };
638
+ }
639
+
640
+ function sendBufferedReplayGapMarker(sendWireMessage, replayGap) {
641
+ if (!activeSession?.isResumed || typeof sendWireMessage !== "function" || !replayGap) {
642
+ return;
643
+ }
644
+
645
+ const envelope = encryptEnvelopePayload(
646
+ {
647
+ payloadText: JSON.stringify({
648
+ method: "remodex/bufferedReplay/gap",
649
+ params: {
650
+ remodexBufferedReplayGap: true,
651
+ ...replayGap,
652
+ },
653
+ }),
654
+ },
655
+ activeSession.macToPhoneKey,
656
+ SECURE_SENDER_MAC,
657
+ activeSession.nextOutboundCounter,
658
+ sessionId,
659
+ activeSession.keyEpoch
660
+ );
661
+ activeSession.nextOutboundCounter += 1;
662
+ sendWireMessage(JSON.stringify(envelope));
663
+ }
664
+
665
+ function sendBufferedReplayResetMarker(
666
+ sendWireMessage,
667
+ resetBridgeOutboundSeqTo,
668
+ replayEpoch
669
+ ) {
670
+ if (!activeSession?.isResumed || typeof sendWireMessage !== "function") {
671
+ return;
672
+ }
673
+
674
+ const envelope = encryptEnvelopePayload(
675
+ {
676
+ payloadText: JSON.stringify({
677
+ method: "remodex/bufferedReplay/reset",
678
+ params: {
679
+ remodexBufferedReplayReset: true,
680
+ resetBridgeOutboundSeqTo,
681
+ bridgeReplayEpoch: replayEpoch,
682
+ },
683
+ }),
684
+ },
685
+ activeSession.macToPhoneKey,
686
+ SECURE_SENDER_MAC,
687
+ activeSession.nextOutboundCounter,
688
+ sessionId,
689
+ activeSession.keyEpoch
690
+ );
691
+ activeSession.nextOutboundCounter += 1;
692
+ sendWireMessage(JSON.stringify(envelope));
693
+ }
694
+
695
+ // Closes a replayed-backlog burst deterministically: the phone batches tagged
696
+ // catch-up events and settles its timeline once on this marker instead of
697
+ // waiting out a debounce. Sent transiently (no bridgeOutboundSeq, never
698
+ // buffered) so it cannot occupy replay-buffer space or be replayed itself.
699
+ function sendBufferedReplayCompleteMarker(sendWireMessage) {
700
+ if (!activeSession?.isResumed || typeof sendWireMessage !== "function") {
701
+ return;
702
+ }
703
+
704
+ const envelope = encryptEnvelopePayload(
705
+ {
706
+ payloadText: JSON.stringify({
707
+ method: "remodex/bufferedReplay/completed",
708
+ params: { remodexBufferedReplayComplete: true },
709
+ }),
710
+ },
711
+ activeSession.macToPhoneKey,
712
+ SECURE_SENDER_MAC,
713
+ activeSession.nextOutboundCounter,
714
+ sessionId,
715
+ activeSession.keyEpoch
716
+ );
717
+ activeSession.nextOutboundCounter += 1;
718
+ sendWireMessage(JSON.stringify(envelope));
719
+ }
720
+
721
+ // Only prior secure-session backlog is catch-up history; same-session retries
722
+ // may be the phone's first delivery of a still-live turn.
723
+ function replayTaggedEntryIfHistorical(entry) {
724
+ if (
725
+ !activeSession
726
+ || entry.bridgeOutboundSeq >= activeSession.firstOutboundSeq
727
+ ) {
728
+ return entry;
729
+ }
730
+
731
+ return replayTaggedEntry(entry);
732
+ }
733
+
734
+ // Marks replayed notifications so the phone applies them as catch-up content
735
+ // instead of live activity; replay must never revive running/streaming UI.
736
+ // RPC responses (id-bearing) and non-object params pass through untouched.
737
+ function replayTaggedEntry(entry) {
738
+ const parsed = safeParseJSON(entry.payloadText);
739
+ if (
740
+ !parsed
741
+ || typeof parsed.method !== "string"
742
+ || parsed.id !== undefined
743
+ || !parsed.params
744
+ || typeof parsed.params !== "object"
745
+ || Array.isArray(parsed.params)
746
+ ) {
747
+ return entry;
748
+ }
749
+
750
+ parsed.params.remodexReplayedEvent = true;
751
+ return {
752
+ bridgeOutboundSeq: entry.bridgeOutboundSeq,
753
+ payloadText: JSON.stringify(parsed),
754
+ sizeBytes: entry.sizeBytes,
755
+ };
561
756
  }
562
757
 
563
758
  return {