@indigoai-us/hq-cloud 6.18.95 → 6.18.97

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.
Files changed (58) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +3 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.js +12 -3
  6. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  7. package/dist/bin/sync-runner.d.ts +8 -0
  8. package/dist/bin/sync-runner.d.ts.map +1 -1
  9. package/dist/bin/sync-runner.js +8 -2
  10. package/dist/bin/sync-runner.js.map +1 -1
  11. package/dist/cli/conflict.d.ts +12 -1
  12. package/dist/cli/conflict.d.ts.map +1 -1
  13. package/dist/cli/conflict.js +6 -2
  14. package/dist/cli/conflict.js.map +1 -1
  15. package/dist/cli/rescue-core.d.ts +3 -2
  16. package/dist/cli/rescue-core.d.ts.map +1 -1
  17. package/dist/cli/rescue-core.js +144 -21
  18. package/dist/cli/rescue-core.js.map +1 -1
  19. package/dist/cli/rescue.d.ts +9 -1
  20. package/dist/cli/rescue.d.ts.map +1 -1
  21. package/dist/cli/rescue.js +47 -11
  22. package/dist/cli/rescue.js.map +1 -1
  23. package/dist/cli/share.d.ts +18 -0
  24. package/dist/cli/share.d.ts.map +1 -1
  25. package/dist/cli/share.js +46 -5
  26. package/dist/cli/share.js.map +1 -1
  27. package/dist/cli/sync.d.ts +37 -1
  28. package/dist/cli/sync.d.ts.map +1 -1
  29. package/dist/cli/sync.js +156 -14
  30. package/dist/cli/sync.js.map +1 -1
  31. package/dist/cli/tombstones.d.ts +8 -0
  32. package/dist/cli/tombstones.d.ts.map +1 -1
  33. package/dist/cli/tombstones.js +9 -0
  34. package/dist/cli/tombstones.js.map +1 -1
  35. package/dist/context.d.ts.map +1 -1
  36. package/dist/context.js +61 -79
  37. package/dist/context.js.map +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/journal.d.ts +57 -0
  43. package/dist/journal.d.ts.map +1 -1
  44. package/dist/journal.js +242 -0
  45. package/dist/journal.js.map +1 -1
  46. package/dist/lib/net-errors.d.ts +14 -0
  47. package/dist/lib/net-errors.d.ts.map +1 -1
  48. package/dist/lib/net-errors.js +49 -0
  49. package/dist/lib/net-errors.js.map +1 -1
  50. package/dist/lib/server-owned-skips.d.ts +64 -0
  51. package/dist/lib/server-owned-skips.d.ts.map +1 -0
  52. package/dist/lib/server-owned-skips.js +182 -0
  53. package/dist/lib/server-owned-skips.js.map +1 -0
  54. package/dist/sync/state-store.d.ts +8 -1
  55. package/dist/sync/state-store.d.ts.map +1 -1
  56. package/dist/sync/state-store.js +96 -9
  57. package/dist/sync/state-store.js.map +1 -1
  58. package/package.json +1 -1
package/dist/journal.js CHANGED
@@ -605,6 +605,242 @@ export function migratePersonalVaultJournal() {
605
605
  const legacy = readJournal("personal");
606
606
  writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, legacy);
607
607
  }
608
+ const PERSONAL_VAULT_OWNER_FORMAT = "hq-personal-vault-owner@2";
609
+ const PERSONAL_VAULT_STASH_FORMAT = "hq-personal-vault-stash@2";
610
+ /**
611
+ * Bind the personal-vault journal to the signed-in identity.
612
+ *
613
+ * feedback_447d143f / feedback_d1296824: the personal-vault journal was keyed
614
+ * only by `PERSONAL_VAULT_JOURNAL_SLUG`. After an identity switch on the same
615
+ * machine, person B's first personal sync read person A's rows, saw every one
616
+ * of A's files missing from B's bucket, treated them as remote deletes, and
617
+ * deleted them locally.
618
+ *
619
+ * Contract (hq-cloud#1028 review):
620
+ * - An owner record holds the person uid plus bucket, region and endpoint.
621
+ * Any mismatch with the caller's identity means the slug journal is not
622
+ * this caller's: its rows are stashed for the recorded owner and the caller
623
+ * gets its own stash back, or an empty journal. An empty journal tracks
624
+ * nothing, so it can never drive a local delete.
625
+ * - Fail closed: a journal with rows and no owner record is adopted only when
626
+ * its pull history names the caller and nobody else. Empty or mixed pull
627
+ * history, an unreadable owner record, or an owner record from an older
628
+ * format are "unproven": the rows are set aside in a recovery stash, a
629
+ * durable `recovery.json` is written, and the caller starts empty. A new
630
+ * install (no journal, or no rows) adopts silently.
631
+ * - Crash safety: the outgoing owner's stash is written first while the owner
632
+ * record still proves whose rows are in the slug journal, then the owner
633
+ * record is marked `pending`, then the slug journal is replaced, then the
634
+ * owner record names the new owner. A crash at any point leaves either the
635
+ * old proven state or a `pending` record, and a `pending` record is never
636
+ * used to stash the slug journal's rows under anyone.
637
+ *
638
+ * Stashes live outside the journal namespace (plain JSON under the state dir)
639
+ * so journal enumeration never mistakes them for a company shard.
640
+ */
641
+ export function bindPersonalVaultJournalToIdentity(identity) {
642
+ const caller = typeof identity === "string" ? { uid: identity } : identity;
643
+ if (!caller.uid)
644
+ return {};
645
+ const ownerPath = personalVaultOwnerPath();
646
+ const read = readPersonalVaultOwner(ownerPath);
647
+ if (read.kind === "ok" && read.record.pending !== true && read.record.owner) {
648
+ const owner = read.record.owner;
649
+ if (identityMatches(owner, caller)) {
650
+ // Fill in vault coordinates the record was missing (uid-only binds).
651
+ const merged = mergeIdentity(owner, caller);
652
+ if (!sameIdentity(merged, owner))
653
+ writePersonalVaultOwner(ownerPath, { format: PERSONAL_VAULT_OWNER_FORMAT, owner: merged });
654
+ return {};
655
+ }
656
+ const current = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
657
+ const previousStash = personalVaultStashPath(owner.uid);
658
+ // Never let an empty journal overwrite the previous owner's real stash.
659
+ if (Object.keys(current.files).length > 0 || !fs.existsSync(previousStash)) {
660
+ writePersonalVaultStash(previousStash, owner, current);
661
+ }
662
+ installPersonalVaultJournal(ownerPath, caller);
663
+ return {};
664
+ }
665
+ if (read.kind === "ok" && read.record.pending === true) {
666
+ // Interrupted switch: the slug journal's rows are unproven. Every stash
667
+ // that mattered was written before `pending`; install the caller's own.
668
+ installPersonalVaultJournal(ownerPath, caller);
669
+ return {};
670
+ }
671
+ const current = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
672
+ const currentRows = Object.keys(current.files).length;
673
+ if (read.kind === "none") {
674
+ const pullOwners = new Set((current.pulls ?? [])
675
+ .map((pull) => pull.companyUid)
676
+ .filter((uid) => typeof uid === "string" && uid.length > 0));
677
+ if (currentRows === 0 || (pullOwners.size === 1 && pullOwners.has(caller.uid))) {
678
+ writePersonalVaultOwner(ownerPath, { format: PERSONAL_VAULT_OWNER_FORMAT, owner: caller });
679
+ return {};
680
+ }
681
+ if (pullOwners.size === 1) {
682
+ // Proven by pull history to belong to exactly one other person; their
683
+ // bucket is unknown, so the stash records the uid only.
684
+ const [otherUid] = [...pullOwners];
685
+ writePersonalVaultStash(personalVaultStashPath(otherUid), { uid: otherUid }, current);
686
+ installPersonalVaultJournal(ownerPath, caller);
687
+ return {};
688
+ }
689
+ }
690
+ // Unproven ownership (no pull history, mixed history, unreadable or
691
+ // old-format owner record): set the rows aside, never delete on them.
692
+ const reason = read.kind === "unreadable" ? "unreadable-owner-record" : "unproven-legacy-journal";
693
+ if (currentRows > 0) {
694
+ const stashName = `unproven-${Date.now()}-${process.pid}.json`;
695
+ writePersonalVaultStash(path.join(personalVaultIdentityDir(), stashName), undefined, current);
696
+ writeJsonAtomic(path.join(personalVaultIdentityDir(), "recovery.json"), {
697
+ format: "hq-personal-vault-recovery@1",
698
+ reason,
699
+ stash: stashName,
700
+ at: new Date().toISOString(),
701
+ });
702
+ }
703
+ installPersonalVaultJournal(ownerPath, caller);
704
+ return currentRows > 0 || read.kind === "unreadable" ? { fenced: reason } : {};
705
+ }
706
+ /**
707
+ * Explicit adoption: the caller asserts that the personal-vault journal now on
708
+ * disk belongs to `identity` (for example a recovery command run by the
709
+ * signed-in person, or a test seeding a journal). This is the only way an
710
+ * unmarked journal with rows is kept without proof from its pull history.
711
+ * It never deletes anything and clears a pending recovery record.
712
+ */
713
+ export function adoptPersonalVaultJournalForIdentity(identity) {
714
+ if (!identity.uid)
715
+ throw new Error("journal: adoption needs a person uid");
716
+ writePersonalVaultOwner(personalVaultOwnerPath(), { format: PERSONAL_VAULT_OWNER_FORMAT, owner: identity });
717
+ fs.rmSync(path.join(personalVaultIdentityDir(), "recovery.json"), { force: true });
718
+ }
719
+ function installPersonalVaultJournal(ownerPath, caller) {
720
+ writePersonalVaultOwner(ownerPath, { format: PERSONAL_VAULT_OWNER_FORMAT, pending: true });
721
+ let next = emptyJournal();
722
+ const stashPath = personalVaultStashPath(caller.uid);
723
+ const stash = readPersonalVaultStash(stashPath);
724
+ // A stash recorded for a different bucket, region or endpoint is not
725
+ // restored; it stays on disk and the caller starts empty.
726
+ if (stash && (!stash.owner || identityMatches(stash.owner, caller)))
727
+ next = stash.journal;
728
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, next);
729
+ writePersonalVaultOwner(ownerPath, {
730
+ format: PERSONAL_VAULT_OWNER_FORMAT,
731
+ owner: stash?.owner ? mergeIdentity(stash.owner, caller) : caller,
732
+ });
733
+ }
734
+ function identityMatches(recorded, caller) {
735
+ if (recorded.uid !== caller.uid)
736
+ return false;
737
+ for (const field of ["bucket", "region", "endpoint"]) {
738
+ if (recorded[field] !== undefined && caller[field] !== undefined && recorded[field] !== caller[field])
739
+ return false;
740
+ }
741
+ return true;
742
+ }
743
+ function mergeIdentity(recorded, caller) {
744
+ const merged = { uid: recorded.uid };
745
+ for (const field of ["bucket", "region", "endpoint"]) {
746
+ const value = recorded[field] ?? caller[field];
747
+ if (value !== undefined)
748
+ merged[field] = value;
749
+ }
750
+ return merged;
751
+ }
752
+ function sameIdentity(a, b) {
753
+ return a.uid === b.uid && a.bucket === b.bucket && a.region === b.region && a.endpoint === b.endpoint;
754
+ }
755
+ function personalVaultIdentityDir() {
756
+ return path.join(getStateDir(), "personal-vault-identities");
757
+ }
758
+ function personalVaultOwnerPath() {
759
+ return path.join(personalVaultIdentityDir(), "owner.json");
760
+ }
761
+ function personalVaultStashPath(identityUid) {
762
+ const id = crypto.createHash("sha256").update(identityUid).digest("hex").slice(0, 32);
763
+ return path.join(personalVaultIdentityDir(), `journal-${id}.json`);
764
+ }
765
+ function readPersonalVaultOwner(ownerPath) {
766
+ let raw;
767
+ try {
768
+ raw = fs.readFileSync(ownerPath, "utf8");
769
+ }
770
+ catch (error) {
771
+ if (error.code === "ENOENT")
772
+ return { kind: "none" };
773
+ return { kind: "unreadable" };
774
+ }
775
+ try {
776
+ const parsed = JSON.parse(raw);
777
+ if (!parsed || parsed.format !== PERSONAL_VAULT_OWNER_FORMAT)
778
+ return { kind: "unreadable" };
779
+ if (parsed.pending === true)
780
+ return { kind: "ok", record: { format: PERSONAL_VAULT_OWNER_FORMAT, pending: true } };
781
+ const owner = parsed.owner;
782
+ if (!owner || typeof owner.uid !== "string" || !owner.uid)
783
+ return { kind: "unreadable" };
784
+ return { kind: "ok", record: { format: PERSONAL_VAULT_OWNER_FORMAT, owner } };
785
+ }
786
+ catch {
787
+ return { kind: "unreadable" };
788
+ }
789
+ }
790
+ function writePersonalVaultOwner(ownerPath, record) {
791
+ writeJsonAtomic(ownerPath, record);
792
+ }
793
+ function writePersonalVaultStash(stashPath, owner, journal) {
794
+ writeJsonAtomic(stashPath, { format: PERSONAL_VAULT_STASH_FORMAT, owner, journal: plainJournalCopy(journal) });
795
+ }
796
+ function readPersonalVaultStash(stashPath) {
797
+ let raw;
798
+ try {
799
+ raw = fs.readFileSync(stashPath, "utf8");
800
+ }
801
+ catch {
802
+ return undefined;
803
+ }
804
+ try {
805
+ const parsed = JSON.parse(raw);
806
+ // Stashes from an older format carry no owner coordinates; they are not
807
+ // trusted and the caller starts empty (no deletes).
808
+ if (parsed?.format !== PERSONAL_VAULT_STASH_FORMAT)
809
+ return undefined;
810
+ const journal = parsed.journal;
811
+ if (!journal || typeof journal !== "object" || !journal.files || typeof journal.files !== "object")
812
+ return undefined;
813
+ migrateToV2(journal);
814
+ return { owner: parsed.owner, journal };
815
+ }
816
+ catch {
817
+ return undefined;
818
+ }
819
+ }
820
+ function writeJsonAtomic(filePath, value) {
821
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
822
+ const tmp = `${filePath}.${process.pid}.tmp`;
823
+ const fd = fs.openSync(tmp, "w");
824
+ try {
825
+ fs.writeSync(fd, JSON.stringify(value));
826
+ fs.fsyncSync(fd);
827
+ }
828
+ finally {
829
+ fs.closeSync(fd);
830
+ }
831
+ fs.renameSync(tmp, filePath);
832
+ }
833
+ function plainJournalCopy(journal) {
834
+ const files = {};
835
+ for (const [key, entry] of Object.entries(journal.files))
836
+ files[key] = { ...entry };
837
+ return {
838
+ version: journal.version,
839
+ lastSync: journal.lastSync,
840
+ files,
841
+ pulls: [...(journal.pulls ?? [])],
842
+ };
843
+ }
608
844
  /**
609
845
  * Read a per-company journal from disk.
610
846
  *
@@ -619,6 +855,8 @@ export function migratePersonalVaultJournal() {
619
855
  * installs never pass through v1 on disk.
620
856
  */
621
857
  export function readJournal(slug, companyUid) {
858
+ if (slug === PERSONAL_VAULT_JOURNAL_SLUG && companyUid)
859
+ bindPersonalVaultJournalToIdentity(companyUid);
622
860
  const areaMigration = openAreaMigrationIfAuthoritative(slug);
623
861
  if (areaMigration) {
624
862
  // Area rows stay in their shared packed store. This record-compatible
@@ -647,6 +885,10 @@ export function readJournal(slug, companyUid) {
647
885
  * @internal Full-pull boundary; not a public journal record API.
648
886
  */
649
887
  export function readJournalForPass(slug, companyUid) {
888
+ // feedback_447d143f: a pass over the personal-vault journal must only ever
889
+ // see the rows of the identity it runs as.
890
+ if (slug === PERSONAL_VAULT_JOURNAL_SLUG && companyUid)
891
+ bindPersonalVaultJournalToIdentity(companyUid);
650
892
  const areaMigration = openAreaMigrationIfAuthoritative(slug);
651
893
  if (!areaMigration)
652
894
  return readJournal(slug, companyUid);