@indigoai-us/hq-cloud 6.11.5 → 6.11.7

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 (119) hide show
  1. package/dist/bin/sync-runner.d.ts +16 -16
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +51 -41
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +108 -33
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +23 -0
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +54 -0
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +142 -0
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +16 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js +1 -62
  16. package/dist/cli/sync.js.map +1 -1
  17. package/dist/cli/tombstones.d.ts +43 -0
  18. package/dist/cli/tombstones.d.ts.map +1 -0
  19. package/dist/cli/tombstones.js +78 -0
  20. package/dist/cli/tombstones.js.map +1 -0
  21. package/dist/context.d.ts +6 -0
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/context.js +57 -17
  24. package/dist/context.js.map +1 -1
  25. package/dist/context.test.js +113 -1
  26. package/dist/context.test.js.map +1 -1
  27. package/dist/entity-resolver.test.js +3 -3
  28. package/dist/entity-resolver.test.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/object-io.d.ts.map +1 -1
  34. package/dist/object-io.js +10 -0
  35. package/dist/object-io.js.map +1 -1
  36. package/dist/personal-vault.d.ts +36 -0
  37. package/dist/personal-vault.d.ts.map +1 -1
  38. package/dist/personal-vault.js +89 -1
  39. package/dist/personal-vault.js.map +1 -1
  40. package/dist/personal-vault.test.js +143 -1
  41. package/dist/personal-vault.test.js.map +1 -1
  42. package/dist/signals/get.d.ts.map +1 -1
  43. package/dist/signals/get.js +7 -11
  44. package/dist/signals/get.js.map +1 -1
  45. package/dist/signals/get.test.js +65 -1
  46. package/dist/signals/get.test.js.map +1 -1
  47. package/dist/signals/internals.d.ts +47 -3
  48. package/dist/signals/internals.d.ts.map +1 -1
  49. package/dist/signals/internals.js +110 -4
  50. package/dist/signals/internals.js.map +1 -1
  51. package/dist/signals/list.d.ts.map +1 -1
  52. package/dist/signals/list.js +16 -23
  53. package/dist/signals/list.js.map +1 -1
  54. package/dist/signals/list.test.js +84 -1
  55. package/dist/signals/list.test.js.map +1 -1
  56. package/dist/signals/types.d.ts +18 -1
  57. package/dist/signals/types.d.ts.map +1 -1
  58. package/dist/sources/get.d.ts.map +1 -1
  59. package/dist/sources/get.js +10 -22
  60. package/dist/sources/get.js.map +1 -1
  61. package/dist/sources/get.test.js +85 -1
  62. package/dist/sources/get.test.js.map +1 -1
  63. package/dist/sources/internals.d.ts +50 -3
  64. package/dist/sources/internals.d.ts.map +1 -1
  65. package/dist/sources/internals.js +113 -4
  66. package/dist/sources/internals.js.map +1 -1
  67. package/dist/sources/list.d.ts.map +1 -1
  68. package/dist/sources/list.js +16 -23
  69. package/dist/sources/list.js.map +1 -1
  70. package/dist/sources/list.test.js +101 -1
  71. package/dist/sources/list.test.js.map +1 -1
  72. package/dist/sources/types.d.ts +18 -1
  73. package/dist/sources/types.d.ts.map +1 -1
  74. package/dist/sync/event-sync.d.ts +6 -7
  75. package/dist/sync/event-sync.d.ts.map +1 -1
  76. package/dist/sync/event-sync.js +6 -7
  77. package/dist/sync/event-sync.js.map +1 -1
  78. package/dist/types.d.ts +33 -3
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/version.d.ts +14 -0
  81. package/dist/version.d.ts.map +1 -0
  82. package/dist/version.js +20 -0
  83. package/dist/version.js.map +1 -0
  84. package/dist/watcher.d.ts.map +1 -1
  85. package/dist/watcher.js +22 -1
  86. package/dist/watcher.js.map +1 -1
  87. package/dist/watcher.test.js +29 -0
  88. package/dist/watcher.test.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/bin/sync-runner.test.ts +131 -41
  91. package/src/bin/sync-runner.ts +56 -48
  92. package/src/cli/share.test.ts +169 -0
  93. package/src/cli/share.ts +81 -0
  94. package/src/cli/sync.ts +21 -88
  95. package/src/cli/tombstones.ts +106 -0
  96. package/src/context.test.ts +139 -1
  97. package/src/context.ts +59 -17
  98. package/src/entity-resolver.test.ts +3 -3
  99. package/src/index.ts +2 -0
  100. package/src/object-io.ts +12 -0
  101. package/src/personal-vault.test.ts +175 -0
  102. package/src/personal-vault.ts +86 -1
  103. package/src/signals/get.test.ts +83 -1
  104. package/src/signals/get.ts +9 -13
  105. package/src/signals/internals.ts +153 -4
  106. package/src/signals/list.test.ts +114 -1
  107. package/src/signals/list.ts +16 -29
  108. package/src/signals/types.ts +18 -1
  109. package/src/sources/get.test.ts +104 -1
  110. package/src/sources/get.ts +12 -24
  111. package/src/sources/internals.ts +156 -4
  112. package/src/sources/list.test.ts +132 -1
  113. package/src/sources/list.ts +16 -29
  114. package/src/sources/types.ts +18 -1
  115. package/src/sync/event-sync.ts +6 -7
  116. package/src/types.ts +33 -3
  117. package/src/version.ts +24 -0
  118. package/src/watcher.test.ts +41 -0
  119. package/src/watcher.ts +24 -1
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared FILE_TOMBSTONE fetch used by BOTH the pull planner (`sync.ts`) and the
3
+ * push planner (`share.ts`).
4
+ *
5
+ * A FILE_TOMBSTONE is the authoritative record of an intentional, scoped delete
6
+ * (`hq files delete <prefix>` → `POST /v1/files/delete` → `recordDeletions`).
7
+ * Both sync legs consult it so a deleted key is neither re-downloaded (pull) nor
8
+ * re-uploaded (push) and therefore cannot be resurrected by a behind peer.
9
+ *
10
+ * Lives in its own module so `share.ts` can use the fetch without importing from
11
+ * `sync.ts` — `sync.ts` already imports values from `share.ts`
12
+ * (`isEphemeralPath`, `isMalformedVaultKey`), so the reverse value-import would
13
+ * create a runtime ESM cycle.
14
+ */
15
+
16
+ import type { VaultServiceConfig } from "../types.js";
17
+ import { toPosixKey } from "../s3.js";
18
+
19
+ /** Timeout for the best-effort FILE_TOMBSTONE fetch (GET /v1/files/tombstones). */
20
+ export const FETCH_TOMBSTONES_TIMEOUT_MS = 5000;
21
+
22
+ /**
23
+ * A FILE_TOMBSTONE as the planners need it: the deleted key + when it was
24
+ * deleted. The `deletedAt` timestamp is the decisive precedence signal on the
25
+ * pull side — a remote object newer than it is a genuine re-create (sync it), an
26
+ * object at or older than it is a stale resurrection of a deleted key (suppress
27
+ * it).
28
+ */
29
+ export interface CompanyTombstone {
30
+ deletedAt: string;
31
+ }
32
+
33
+ /**
34
+ * Fetch the company's FILE_TOMBSTONE rows from hq-pro (GET /v1/files/tombstones)
35
+ * and return them as a POSIX-keyed map the planners consult to avoid
36
+ * resurrecting an intentionally-deleted object (delete-resync). The endpoint is
37
+ * ACL-filtered server-side, so the map only ever contains keys this caller can
38
+ * read — exactly the keys that can appear in the (STS-scoped) remote LIST.
39
+ *
40
+ * Best-effort and bounded by a 5s timeout: a tombstone read that fails, times
41
+ * out, or returns non-2xx degrades to an EMPTY map — i.e. to the pre-fix
42
+ * behavior (no suppression). That is the safe failure direction: a missed
43
+ * tombstone re-pulls/re-pushes a deleted file (a known, recoverable annoyance),
44
+ * whereas a spurious tombstone would HIDE a file the user wants. The failure is
45
+ * logged (never silently swallowed) so a persistently-degraded read is visible.
46
+ */
47
+ export async function fetchCompanyTombstones(
48
+ vaultConfig: VaultServiceConfig,
49
+ companyUid: string,
50
+ ): Promise<Map<string, CompanyTombstone>> {
51
+ const out = new Map<string, CompanyTombstone>();
52
+ try {
53
+ const token =
54
+ typeof vaultConfig.authToken === "function"
55
+ ? await vaultConfig.authToken()
56
+ : vaultConfig.authToken;
57
+ const base = vaultConfig.apiUrl.replace(/\/+$/, "");
58
+ const url = `${base}/v1/files/tombstones?company=${encodeURIComponent(
59
+ companyUid,
60
+ )}`;
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(
63
+ () => controller.abort(),
64
+ FETCH_TOMBSTONES_TIMEOUT_MS,
65
+ );
66
+ try {
67
+ const res = await fetch(url, {
68
+ method: "GET",
69
+ headers: { Authorization: `Bearer ${token}` },
70
+ signal: controller.signal,
71
+ });
72
+ if (!res.ok) {
73
+ // Non-2xx is non-fatal: log and degrade to no-suppression. A 404 means
74
+ // the endpoint is not deployed yet (hq-pro release lag) — the sync
75
+ // proceeds with the legacy behavior until it lands.
76
+ console.error(
77
+ `[hq-sync] tombstone fetch returned ${res.status} (degrading to no-suppression)`,
78
+ );
79
+ return out;
80
+ }
81
+ const body = (await res.json()) as {
82
+ tombstones?: Array<{ key?: string; deletedAt?: string }>;
83
+ };
84
+ for (const t of body.tombstones ?? []) {
85
+ if (typeof t.key === "string" && typeof t.deletedAt === "string") {
86
+ out.set(toPosixKey(t.key), { deletedAt: t.deletedAt });
87
+ }
88
+ }
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+ } catch (err) {
93
+ // Best-effort: a failed tombstone read must never break the sync. Log
94
+ // (policy: never silently swallow) and degrade to no-suppression.
95
+ try {
96
+ console.error(
97
+ `[hq-sync] tombstone fetch failed (non-fatal, degrading to no-suppression): ${
98
+ err instanceof Error ? err.message : String(err)
99
+ }`,
100
+ );
101
+ } catch {
102
+ // swallow — logging must never break sync
103
+ }
104
+ }
105
+ return out;
106
+ }
@@ -119,7 +119,7 @@ describe("resolveEntityContext", () => {
119
119
 
120
120
  expect(ctx.uid).toBe("cmp_01ABCDEF");
121
121
  expect(ctx.bucketName).toBe("hq-vault-acme-123");
122
- expect(ctx.credentials.accessKeyId).toBe("ASIA_TEST_KEY");
122
+ expect(ctx.credentials?.accessKeyId).toBe("ASIA_TEST_KEY");
123
123
  expect(ctx.region).toBe("us-east-1");
124
124
 
125
125
  // Verify entity lookup used the new per-user-namespace endpoint
@@ -327,6 +327,144 @@ describe("routing by UID prefix and vend-self dispatch", () => {
327
327
  });
328
328
  });
329
329
 
330
+ // ---------------------------------------------------------------------------
331
+ // HQ-59 company vend-skip (presign transport)
332
+ //
333
+ // When the run uses the presigned-URL transport for company vaults
334
+ // (`companyVaultUsesPresign`), resolveEntityContext must NOT call the company
335
+ // `/sts/vend` route — the presign transport needs no STS creds, and the mere
336
+ // presence of a cmp_ vend call is the pre-6.11.6 signal the hq-pro min-version
337
+ // gate denies on. Personal vaults must still vend self.
338
+ // ---------------------------------------------------------------------------
339
+ describe("HQ-59 company vend-skip (companyVaultUsesPresign)", () => {
340
+ const presignConfig: VaultServiceConfig = {
341
+ ...mockConfig,
342
+ companyVaultUsesPresign: true,
343
+ };
344
+
345
+ beforeEach(() => {
346
+ clearContextCache();
347
+ vi.restoreAllMocks();
348
+ });
349
+
350
+ it("cmp_* UID: SKIPS /sts/vend, returns no credentials + a far-future expiry", async () => {
351
+ const calls: string[] = [];
352
+ vi.stubGlobal(
353
+ "fetch",
354
+ vi.fn().mockImplementation(async (url: string) => {
355
+ const u = String(url);
356
+ calls.push(u);
357
+ if (u.includes("/entity/cmp_")) {
358
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
359
+ }
360
+ // If a vend ever fires, surface it as a hard failure so the test fails
361
+ // LOUDLY rather than silently allowing the route the gate watches.
362
+ if (u.includes("/sts/vend")) {
363
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
364
+ }
365
+ return { ok: false, status: 404, text: async () => "Not found" };
366
+ }),
367
+ );
368
+
369
+ const ctx = await resolveEntityContext("cmp_01ABCDEF", presignConfig);
370
+
371
+ // The load-bearing assertion: NO company vend route was hit.
372
+ expect(calls.some((u) => u.includes("/sts/vend"))).toBe(false);
373
+ expect(calls.some((u) => u.includes("/entity/cmp_01ABCDEF"))).toBe(true);
374
+ // Credential-less context with a sentinel far-future expiry → never re-vends.
375
+ expect(ctx.credentials).toBeUndefined();
376
+ expect(ctx.bucketName).toBe(mockEntity.bucketName);
377
+ expect(isExpiringSoon(ctx.expiresAt)).toBe(false);
378
+ });
379
+
380
+ it("company slug: SKIPS /sts/vend after the namespace + entity lookup", async () => {
381
+ const calls: string[] = [];
382
+ vi.stubGlobal(
383
+ "fetch",
384
+ vi.fn().mockImplementation(async (url: string) => {
385
+ const u = String(url);
386
+ calls.push(u);
387
+ if (u.includes("/entity/check-slug/me")) {
388
+ return {
389
+ ok: true,
390
+ status: 200,
391
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
392
+ text: async () => "",
393
+ };
394
+ }
395
+ if (u.includes(`/entity/${mockEntity.uid}`)) {
396
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
397
+ }
398
+ if (u.includes("/sts/vend")) {
399
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
400
+ }
401
+ return { ok: false, status: 404, text: async () => "Not found" };
402
+ }),
403
+ );
404
+
405
+ const ctx = await resolveEntityContext("acme", presignConfig);
406
+
407
+ expect(calls.some((u) => u.includes("/sts/vend"))).toBe(false);
408
+ expect(ctx.credentials).toBeUndefined();
409
+ });
410
+
411
+ it("prs_* UID: STILL vends self even with the company flag set", async () => {
412
+ const prsEntity = {
413
+ uid: "prs_01PERSON",
414
+ slug: "test-person",
415
+ bucketName: "hq-vault-prs-01person",
416
+ status: "active",
417
+ };
418
+ const calls: string[] = [];
419
+ vi.stubGlobal(
420
+ "fetch",
421
+ vi.fn().mockImplementation(async (url: string) => {
422
+ const u = String(url);
423
+ calls.push(u);
424
+ if (u.includes("/entity/prs_")) {
425
+ return { ok: true, status: 200, json: async () => ({ entity: prsEntity }), text: async () => "" };
426
+ }
427
+ if (u.includes("/sts/vend-self")) {
428
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
429
+ }
430
+ return { ok: false, status: 404, text: async () => "Not found" };
431
+ }),
432
+ );
433
+
434
+ const ctx = await resolveEntityContext("prs_01PERSON", presignConfig);
435
+
436
+ const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
437
+ expect(vendCalls).toHaveLength(1);
438
+ expect(vendCalls[0]).toContain("/sts/vend-self");
439
+ expect(ctx.credentials?.accessKeyId).toBe(mockVendResponse.credentials.accessKeyId);
440
+ });
441
+
442
+ it("flag OFF (default): cmp_* still vends — no behavior change without the flag", async () => {
443
+ const calls: string[] = [];
444
+ vi.stubGlobal(
445
+ "fetch",
446
+ vi.fn().mockImplementation(async (url: string) => {
447
+ const u = String(url);
448
+ calls.push(u);
449
+ if (u.includes("/entity/cmp_")) {
450
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
451
+ }
452
+ if (u.includes("/sts/vend")) {
453
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
454
+ }
455
+ return { ok: false, status: 404, text: async () => "Not found" };
456
+ }),
457
+ );
458
+
459
+ const ctx = await resolveEntityContext("cmp_01ABCDEF", mockConfig);
460
+
461
+ const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
462
+ expect(vendCalls).toHaveLength(1);
463
+ expect(vendCalls[0]).toContain("/sts/vend");
464
+ expect(ctx.credentials?.accessKeyId).toBe(mockVendResponse.credentials.accessKeyId);
465
+ });
466
+ });
467
+
330
468
  describe("refreshEntityContext", () => {
331
469
  beforeEach(() => {
332
470
  clearContextCache();
package/src/context.ts CHANGED
@@ -15,6 +15,14 @@ const REFRESH_THRESHOLD_MS = 2 * 60 * 1000;
15
15
  /** STS session duration requested from vault-service (15 minutes). */
16
16
  const DEFAULT_SESSION_DURATION_SECONDS = 900;
17
17
 
18
+ /**
19
+ * `expiresAt` stamped on a presign-only company context (HQ-59). No STS vend
20
+ * happened, so there is nothing to expire — a far-future sentinel keeps
21
+ * `isExpiringSoon` false forever, so the auto-refresh path never re-vends (and
22
+ * thus never re-hits) the company `/sts/vend` route the gate keys on.
23
+ */
24
+ const PRESIGN_NO_VEND_EXPIRES_AT = "9999-12-31T23:59:59.000Z";
25
+
18
26
  /**
19
27
  * Cached contexts.
20
28
  *
@@ -78,6 +86,12 @@ export const KNOWN_UID_PREFIXES = ["cmp_", "prs_"] as const;
78
86
  *
79
87
  * Caches the result and auto-refreshes when the credentials are within
80
88
  * 2 minutes of expiry.
89
+ *
90
+ * HQ-59: when `config.companyVaultUsesPresign` is set, COMPANY (`cmp_*`)
91
+ * contexts skip the `POST /sts/vend` call and come back credential-less with a
92
+ * far-future expiry — the presign transport needs no STS creds, and skipping
93
+ * the vend keeps a compliant sync-runner off the company vend route. Personal
94
+ * (`prs_*`) contexts always vend self.
81
95
  */
82
96
  export async function resolveEntityContext(
83
97
  companyUidOrSlug: string,
@@ -122,26 +136,54 @@ export async function resolveEntityContext(
122
136
  );
123
137
  }
124
138
 
125
- // Step 2: Dispatch credential vending by UID prefix.
139
+ // Step 2: Dispatch credential vending by RESOLVED-uid prefix.
126
140
  // cmp_* → POST /sts/vend (company path; membership-gated)
127
141
  // prs_* → POST /sts/vend-self (person path; self-ownership-gated)
128
- // slug → POST /sts/vend (legacy slug path is company-only)
129
- const vendResult = looksLikePerson
130
- ? await vendSelfCredentials(entity.uid, config)
131
- : await vendCredentials(entity.uid, config);
142
+ // slug → resolves to a cmp_* (the slug path is company-only)
143
+ //
144
+ // HQ-59 vend-skip: when the run uses the presigned-URL transport for company
145
+ // vaults, a `cmp_*` context needs no STS creds (the presign transport
146
+ // authorizes every object server-side) AND a compliant sync-runner must not
147
+ // call the company `/sts/vend` route at all — its mere presence is the
148
+ // pre-6.11.6 signal the hq-pro min-version gate denies on. So skip the vend
149
+ // entirely and return a credential-less context with a sentinel expiry. The
150
+ // discriminator is the RESOLVED entity uid (`cmp_`), the exact same prefix
151
+ // `presignObjectIOFactory` uses to route to `PresignObjectIO` — so the
152
+ // vend-skip and the transport choice can never disagree. Personal vaults
153
+ // (`prs_*`) always vend self.
154
+ const isCompanyEntity = entity.uid.startsWith("cmp_");
155
+ const skipCompanyVend =
156
+ config.companyVaultUsesPresign === true && isCompanyEntity;
132
157
 
133
- const ctx: EntityContext = {
134
- uid: entity.uid,
135
- slug: entity.slug,
136
- bucketName: entity.bucketName,
137
- region: config.region ?? "us-east-1",
138
- credentials: {
139
- accessKeyId: vendResult.credentials.accessKeyId,
140
- secretAccessKey: vendResult.credentials.secretAccessKey,
141
- sessionToken: vendResult.credentials.sessionToken,
142
- },
143
- expiresAt: vendResult.expiresAt,
144
- };
158
+ let ctx: EntityContext;
159
+ if (skipCompanyVend) {
160
+ ctx = {
161
+ uid: entity.uid,
162
+ slug: entity.slug,
163
+ bucketName: entity.bucketName,
164
+ region: config.region ?? "us-east-1",
165
+ // No STS vend on the presign path — see PRESIGN_NO_VEND_EXPIRES_AT.
166
+ credentials: undefined,
167
+ expiresAt: PRESIGN_NO_VEND_EXPIRES_AT,
168
+ };
169
+ } else {
170
+ const vendResult = looksLikePerson
171
+ ? await vendSelfCredentials(entity.uid, config)
172
+ : await vendCredentials(entity.uid, config);
173
+
174
+ ctx = {
175
+ uid: entity.uid,
176
+ slug: entity.slug,
177
+ bucketName: entity.bucketName,
178
+ region: config.region ?? "us-east-1",
179
+ credentials: {
180
+ accessKeyId: vendResult.credentials.accessKeyId,
181
+ secretAccessKey: vendResult.credentials.secretAccessKey,
182
+ sessionToken: vendResult.credentials.sessionToken,
183
+ },
184
+ expiresAt: vendResult.expiresAt,
185
+ };
186
+ }
145
187
 
146
188
  // Cache by UID (globally unique) and — if the caller asked by slug —
147
189
  // by `(callerSub, slug)` so the same slug can resolve to different
@@ -171,9 +171,9 @@ describe("resolveEntity", () => {
171
171
  expect(ctx.slug).toBe("indigo");
172
172
  expect(ctx.bucketName).toBe("hq-vault-indigo");
173
173
  expect(ctx.region).toBe("us-east-1");
174
- expect(ctx.credentials.accessKeyId).toBe("ASIAMOCK000000000001");
175
- expect(ctx.credentials.secretAccessKey).toBe("mockSecret");
176
- expect(ctx.credentials.sessionToken).toBe("mockSession");
174
+ expect(ctx.credentials?.accessKeyId).toBe("ASIAMOCK000000000001");
175
+ expect(ctx.credentials?.secretAccessKey).toBe("mockSecret");
176
+ expect(ctx.credentials?.sessionToken).toBe("mockSession");
177
177
  expect(ctx.expiresAt).toBeTruthy();
178
178
  });
179
179
 
package/src/index.ts CHANGED
@@ -142,8 +142,10 @@ export type { PullScope, PullScopeClient } from "./sync/pull-scope.js";
142
142
  export {
143
143
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
144
144
  PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
145
+ CONTINUITY_POINTER_REL,
145
146
  computePersonalVaultPaths,
146
147
  computePersonalCompanySubdirs,
148
+ computeContinuityPointerPaths,
147
149
  } from "./personal-vault.js";
148
150
  export type { PersonalVaultOptions } from "./personal-vault.js";
149
151
 
package/src/object-io.ts CHANGED
@@ -199,6 +199,18 @@ export class S3SdkObjectIO implements ObjectIO {
199
199
 
200
200
  constructor(ctx: EntityContext) {
201
201
  this.bucket = ctx.bucketName;
202
+ if (!ctx.credentials) {
203
+ // A credential-less context only exists on the presign path (HQ-59
204
+ // company vaults skip the STS vend). Such a context must route through
205
+ // PresignObjectIO, never here — reaching the direct-S3 transport with no
206
+ // creds is a routing bug, so fail loudly instead of building a broken
207
+ // S3 client that would later 403 with an opaque AWS error.
208
+ throw new Error(
209
+ `S3SdkObjectIO requires STS credentials but got a presign-only ` +
210
+ `context for ${ctx.uid}; company presign contexts must use ` +
211
+ `PresignObjectIO. This is a transport-routing bug.`,
212
+ );
213
+ }
202
214
  this.client = new S3Client({
203
215
  region: ctx.region,
204
216
  credentials: {
@@ -16,8 +16,10 @@ import * as fs from "fs";
16
16
  import * as os from "os";
17
17
  import * as path from "path";
18
18
  import {
19
+ computeContinuityPointerPaths,
19
20
  computePersonalCompanySubdirs,
20
21
  computePersonalVaultPaths,
22
+ CONTINUITY_POINTER_REL,
21
23
  PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
22
24
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
23
25
  } from "./personal-vault.js";
@@ -347,3 +349,176 @@ describe("personal-vault helpers", () => {
347
349
  ).toBe(false);
348
350
  });
349
351
  });
352
+
353
+ // ─────────────────────────────────────────────────────────────────────────
354
+ // DEV-1778 — session-continuity pointer carve-out.
355
+ //
356
+ // `workspace/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL (machine-local by
357
+ // design). The ONE exception is the session pointer
358
+ // `workspace/threads/handoff.json` + the single thread file it references via
359
+ // `thread_path`, so a `/handoff` on one machine reaches a second machine.
360
+ // Mirrors the companies/manifest.yaml special-case: a narrow file-level
361
+ // re-include that never broadens the rest of workspace/.
362
+ // ─────────────────────────────────────────────────────────────────────────
363
+ describe("personal-vault: continuity-pointer carve-out", () => {
364
+ let hqRoot: string;
365
+
366
+ beforeEach(() => {
367
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cont-test-"));
368
+ });
369
+
370
+ afterEach(() => {
371
+ fs.rmSync(hqRoot, { recursive: true, force: true });
372
+ });
373
+
374
+ /** Helper: relative-paths-sorted projection of an absolute-path list. */
375
+ function rel(abs: string[]): string[] {
376
+ return abs.map((p) => path.relative(hqRoot, p)).sort();
377
+ }
378
+
379
+ const THREADS = path.join("workspace", "threads");
380
+
381
+ /** Write workspace/threads/handoff.json with the given object. */
382
+ function writeHandoff(obj: unknown, raw?: string): void {
383
+ const dir = path.join(hqRoot, THREADS);
384
+ fs.mkdirSync(dir, { recursive: true });
385
+ fs.writeFileSync(
386
+ path.join(dir, "handoff.json"),
387
+ raw !== undefined ? raw : JSON.stringify(obj),
388
+ );
389
+ }
390
+
391
+ /** Write a thread file under workspace/threads/ and return its rel path. */
392
+ function writeThread(name: string): string {
393
+ const dir = path.join(hqRoot, THREADS);
394
+ fs.mkdirSync(dir, { recursive: true });
395
+ fs.writeFileSync(path.join(dir, name), "{}");
396
+ return path.join(THREADS, name).split(path.sep).join("/");
397
+ }
398
+
399
+ it("constant: CONTINUITY_POINTER_REL is the canonical pointer path", () => {
400
+ expect(CONTINUITY_POINTER_REL).toBe("workspace/threads/handoff.json");
401
+ });
402
+
403
+ it("includes handoff.json + the thread file it points to", () => {
404
+ const threadRel = writeThread("T-20260617-1200-demo.json");
405
+ writeHandoff({ thread_path: threadRel });
406
+
407
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual(
408
+ [
409
+ path.join(THREADS, "T-20260617-1200-demo.json"),
410
+ path.join(THREADS, "handoff.json"),
411
+ ].sort(),
412
+ );
413
+ });
414
+
415
+ it("carve-out composes into computePersonalVaultPaths", () => {
416
+ const threadRel = writeThread("T-20260617-1200-demo.json");
417
+ writeHandoff({ thread_path: threadRel });
418
+ fs.mkdirSync(path.join(hqRoot, ".claude"));
419
+
420
+ const out = rel(computePersonalVaultPaths(hqRoot));
421
+ expect(out).toContain(path.join(THREADS, "handoff.json"));
422
+ expect(out).toContain(path.join(THREADS, "T-20260617-1200-demo.json"));
423
+ // Sibling top-level dirs still travel; the rest of workspace/ does not.
424
+ expect(out).toContain(".claude");
425
+ });
426
+
427
+ it("does NOT broaden the rest of workspace/ (siblings/other threads stay local)", () => {
428
+ const threadRel = writeThread("T-active.json");
429
+ writeThread("T-old.json"); // an inactive thread — must NOT sync
430
+ writeHandoff({ thread_path: threadRel });
431
+ // Unrelated workspace litter that must stay machine-local.
432
+ fs.mkdirSync(path.join(hqRoot, "workspace", "locks"), { recursive: true });
433
+ fs.writeFileSync(path.join(hqRoot, "workspace", "locks", "x.lock"), "1");
434
+ fs.writeFileSync(path.join(hqRoot, "workspace", "threads", "INDEX.md"), "#");
435
+
436
+ const out = rel(computePersonalVaultPaths(hqRoot));
437
+ expect(out).toContain(path.join(THREADS, "handoff.json"));
438
+ expect(out).toContain(path.join(THREADS, "T-active.json"));
439
+ expect(out).not.toContain(path.join(THREADS, "T-old.json"));
440
+ expect(out).not.toContain(path.join(THREADS, "INDEX.md"));
441
+ expect(out.some((p) => p.startsWith(path.join("workspace", "locks")))).toBe(
442
+ false,
443
+ );
444
+ });
445
+
446
+ it("handoff.json present but thread_path absent → only the pointer", () => {
447
+ writeHandoff({ message: "no pointer field" });
448
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
449
+ path.join(THREADS, "handoff.json"),
450
+ ]);
451
+ });
452
+
453
+ it("handoff.json points to a non-existent thread file → only the pointer", () => {
454
+ writeHandoff({ thread_path: "workspace/threads/T-ghost.json" });
455
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
456
+ path.join(THREADS, "handoff.json"),
457
+ ]);
458
+ });
459
+
460
+ it("no handoff.json at all → empty (nothing to carry)", () => {
461
+ fs.mkdirSync(path.join(hqRoot, THREADS), { recursive: true });
462
+ expect(computeContinuityPointerPaths(hqRoot)).toEqual([]);
463
+ });
464
+
465
+ it("malformed handoff.json → fail-soft to pointer only (no throw)", () => {
466
+ writeHandoff(null, "{ this is : not json ]");
467
+ expect(() => computeContinuityPointerPaths(hqRoot)).not.toThrow();
468
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
469
+ path.join(THREADS, "handoff.json"),
470
+ ]);
471
+ });
472
+
473
+ // ── Security: thread_path must never escape workspace/threads/ ──────────
474
+ it("rejects an absolute thread_path (only the pointer is included)", () => {
475
+ // Put a real file at the absolute target so the ONLY thing rejecting it
476
+ // is the containment guard, not a missing-file fallthrough.
477
+ const evil = path.join(hqRoot, "secret.txt");
478
+ fs.writeFileSync(evil, "top secret");
479
+ writeHandoff({ thread_path: evil });
480
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
481
+ path.join(THREADS, "handoff.json"),
482
+ ]);
483
+ });
484
+
485
+ it("rejects a traversal thread_path (../../.env) — no smuggling out of threads/", () => {
486
+ fs.writeFileSync(path.join(hqRoot, ".env"), "SECRET=1");
487
+ writeHandoff({ thread_path: "workspace/threads/../../.env" });
488
+ const out = rel(computeContinuityPointerPaths(hqRoot));
489
+ expect(out).toEqual([path.join(THREADS, "handoff.json")]);
490
+ expect(out.some((p) => p.endsWith(".env"))).toBe(false);
491
+ });
492
+
493
+ it("rejects a thread_path under workspace/ but outside threads/", () => {
494
+ fs.mkdirSync(path.join(hqRoot, "workspace", "reports"), {
495
+ recursive: true,
496
+ });
497
+ fs.writeFileSync(
498
+ path.join(hqRoot, "workspace", "reports", "secret.md"),
499
+ "x",
500
+ );
501
+ writeHandoff({ thread_path: "workspace/reports/secret.md" });
502
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
503
+ path.join(THREADS, "handoff.json"),
504
+ ]);
505
+ });
506
+
507
+ it("rejects a symlink that escapes threads/ (realpath containment)", () => {
508
+ // A thread_path that names an in-threads symlink whose target is OUTSIDE
509
+ // threads/ must be rejected by the realpath re-check.
510
+ fs.writeFileSync(path.join(hqRoot, "outside.txt"), "secret");
511
+ fs.mkdirSync(path.join(hqRoot, THREADS), { recursive: true });
512
+ const link = path.join(hqRoot, THREADS, "escape.json");
513
+ try {
514
+ fs.symlinkSync(path.join(hqRoot, "outside.txt"), link);
515
+ } catch {
516
+ // Platform without symlink support — skip the assertion gracefully.
517
+ return;
518
+ }
519
+ writeHandoff({ thread_path: "workspace/threads/escape.json" });
520
+ expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
521
+ path.join(THREADS, "handoff.json"),
522
+ ]);
523
+ });
524
+ });