@indigoai-us/hq-cloud 6.11.0 → 6.11.2

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.
@@ -190,6 +190,43 @@ describe("sync — scope-aware download (US-005)", () => {
190
190
  expect(result.filesOutOfScope).toBe(2);
191
191
  });
192
192
 
193
+ // FILE_TOMBSTONE fetch is COMPANY-scoped (GET /v1/files/tombstones?company=…).
194
+ // For the personal vault the target uid is a personUid (prs_…), which the
195
+ // server correctly 403s ("No active membership for caller in company prs_…"),
196
+ // spamming hq-pro's Sentry with a per-user no-membership cluster. The pull
197
+ // must SKIP the fetch for the personal target (delete-resync was never a
198
+ // committed personal-vault feature). Differential: company-mode fetches,
199
+ // personal-mode does not.
200
+ const hitTombstones = (m: ReturnType<typeof setupFetchMock>) =>
201
+ m.mock.calls.some((c: unknown[]) =>
202
+ String(c[0]).includes("/v1/files/tombstones"),
203
+ );
204
+
205
+ it("company-mode pull fetches FILE_TOMBSTONEs (baseline for the personal guard)", async () => {
206
+ const fetchMock = setupFetchMock();
207
+ await sync({
208
+ company: "acme",
209
+ vaultConfig: mockConfig,
210
+ hqRoot: tmpDir,
211
+ syncMode: "all",
212
+ prefixSet: [],
213
+ });
214
+ expect(hitTombstones(fetchMock)).toBe(true);
215
+ });
216
+
217
+ it("personal-vault pull SKIPS the company-scoped tombstone fetch (no 403 → no Sentry no-membership spam)", async () => {
218
+ const fetchMock = setupFetchMock();
219
+ await sync({
220
+ company: "acme",
221
+ vaultConfig: mockConfig,
222
+ hqRoot: tmpDir,
223
+ syncMode: "all",
224
+ prefixSet: [],
225
+ personalMode: true,
226
+ });
227
+ expect(hitTombstones(fetchMock)).toBe(false);
228
+ });
229
+
193
230
  it("is idempotent: a second shared pull downloads and removes nothing", async () => {
194
231
  const opts = {
195
232
  company: "acme",
package/src/cli/sync.ts CHANGED
@@ -708,7 +708,24 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
708
708
  // a clean read of `ctx`; best-effort — a failed read degrades to an empty map
709
709
  // (no suppression), preserving the pre-fix behavior. ctx.uid is the verified
710
710
  // companyUid the tombstone rows are keyed under.
711
- const tombstones = await fetchCompanyTombstones(vaultConfig, ctx.uid);
711
+ //
712
+ // SKIP for the personal vault: its `ctx.uid` is a personUid (`prs_…`), but
713
+ // `GET /v1/files/tombstones?company=…` is COMPANY-scoped server-side
714
+ // (findCallerWithMembership), so a personal-vault request resolves
715
+ // `company=prs_…` to no membership and is correctly rejected with
716
+ // `403 "No active membership for caller in company prs_…"`. That 403 is
717
+ // benign for the pull (it already degrades to the empty map below), but
718
+ // hq-pro captures EVERY one as a Sentry warning — the per-personal-vault
719
+ // no-membership cluster (one Sentry issue per signed-in user). Personal-vault
720
+ // delete-resync was never a committed feature and there is no person-scoped
721
+ // tombstone path, so for the personal target we skip the fetch and use an
722
+ // empty map — byte-for-byte the current degraded behavior, minus the 403 spam.
723
+ // FUTURE FOLLOW-UP (not built here): if personal-vault delete-resync is
724
+ // wanted, it needs a real person-scoped tombstone endpoint + client read.
725
+ const tombstones =
726
+ options.personalMode === true
727
+ ? new Map<string, CompanyTombstone>()
728
+ : await fetchCompanyTombstones(vaultConfig, ctx.uid);
712
729
 
713
730
  // Stage 1: classify every remote file against the journal + local disk.
714
731
  // Hashing happens here (not in the transfer loop) so the plan event below
@@ -82,6 +82,20 @@ export async function resolvePullScope(
82
82
  const memberships = await client.listMyMemberships();
83
83
  const m = memberships.find((x) => x.companyUid === companyUid);
84
84
  if (!m) return { syncMode: "all" };
85
+ // Agent memberships (`agt_…#cmp_…`) belong to an agent identity that owns NO
86
+ // person entity. hq-pro's per-membership sync-config endpoint sits behind an
87
+ // up-front person-gate, so an agent caller is rejected with
88
+ // `403 "Caller has no person entity — call POST /entity with type=person
89
+ // first"` BEFORE the route's own ownership check ever runs — captured
90
+ // server-side as the Sentry warning HQ-1R (one event per agent sync pass).
91
+ // Agents never set a scoped sync-config (that is a human-only menubar
92
+ // action) and a failed/absent config already degrades to `all` here, so the
93
+ // call is guaranteed to 403 and change nothing. Skip it for agent
94
+ // memberships and resolve `all` directly — behavior-preserving, minus the
95
+ // 403 spam. (A server-side alternative would be to exempt sync-config from
96
+ // the up-front person-gate and let its own `authorizeSyncConfigAccess`
97
+ // handle agent ownership; tracked as a possible follow-up.)
98
+ if (m.membershipKey?.startsWith("agt_")) return { syncMode: "all" };
85
99
  const cfg = await client.getMembershipSyncConfig(m.membershipKey);
86
100
  if (cfg.syncMode === "all") return { syncMode: "all" };
87
101