@indigoai-us/hq-cli 5.26.0 → 5.29.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.
@@ -24,7 +24,7 @@
24
24
  * for the destructive side effects (delete, tombstone, PUT sync-config).
25
25
  */
26
26
 
27
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="77563dec-63f2-52df-9138-c88bfd1600b9")}catch(e){}}();
27
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="20a280f3-0fed-5868-9ad4-fcc562253486")}catch(e){}}();
28
28
  import * as fs from "node:fs";
29
29
  import * as path from "node:path";
30
30
  import { hashFile, isCoveredByAny, } from "@indigoai-us/hq-cloud";
@@ -55,7 +55,16 @@ export function buildNarrowPlan(input) {
55
55
  if (!fs.existsSync(walkRoot)) {
56
56
  return emptyPlan();
57
57
  }
58
- walkLocal(walkRoot, hqRoot, (file) => {
58
+ // Walk with `walkRoot` as the rel-root so each file's `relPath` is
59
+ // COMPANY-RELATIVE (e.g. `meetings/a.md`) — the same namespace as the
60
+ // server's explicit-grant paths, the per-company journal keys, and the
61
+ // hq-cloud sync engine's `RemoteFile.key`. Computing it relative to `hqRoot`
62
+ // (the old behavior) produced `companies/<slug>/meetings/a.md`, which
63
+ // matched neither the company-relative grants nor the journal keys — so
64
+ // every file fell out of `prospectivePrefixSet` AND missed its journal
65
+ // entry, making narrow flag the entire tree as dirty orphans. See the
66
+ // namespace contract in hq-cloud `scope-shrink.ts` / `prefix-coalesce.ts`.
67
+ walkLocal(walkRoot, walkRoot, (file) => {
59
68
  if (isCoveredByAny(file.relPath, prospectivePrefixSet)) {
60
69
  staying.push(file);
61
70
  return;
@@ -100,7 +109,10 @@ function emptyPlan() {
100
109
  * Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
101
110
  * target chain (matches the share-engine convention).
102
111
  */
103
- function walkLocal(dir, hqRoot, emit) {
112
+ function walkLocal(dir,
113
+ // Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
114
+ // so emitted `relPath`s are company-relative. Fixed across recursion.
115
+ relRoot, emit) {
104
116
  let entries;
105
117
  try {
106
118
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -115,7 +127,7 @@ function walkLocal(dir, hqRoot, emit) {
115
127
  }
116
128
  for (const entry of entries) {
117
129
  const absPath = path.join(dir, entry.name);
118
- const relPath = path.relative(hqRoot, absPath);
130
+ const relPath = path.relative(relRoot, absPath);
119
131
  if (entry.isSymbolicLink()) {
120
132
  // Record the link as a file-like entry. Don't descend — narrow is
121
133
  // about pruning files that the LOCAL tree has materialized here; a
@@ -132,7 +144,7 @@ function walkLocal(dir, hqRoot, emit) {
132
144
  continue;
133
145
  }
134
146
  if (entry.isDirectory()) {
135
- walkLocal(absPath, hqRoot, emit);
147
+ walkLocal(absPath, relRoot, emit);
136
148
  continue;
137
149
  }
138
150
  if (entry.isFile()) {
@@ -241,4 +253,4 @@ export function formatBytes(n) {
241
253
  return `${v.toFixed(2)} ${units[i]}`;
242
254
  }
243
255
  //# sourceMappingURL=local-tree-diff.js.map
244
- //# debugId=77563dec-63f2-52df-9138-c88bfd1600b9
256
+ //# debugId=20a280f3-0fed-5868-9ad4-fcc562253486
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.26.0",
3
+ "version": "5.29.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "~5.39.0",
18
+ "@indigoai-us/hq-cloud": "~5.43.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -1,17 +1,22 @@
1
1
  /**
2
2
  * Unit tests for `hq files browse` + `hq files cat` (files-browse.ts).
3
3
  *
4
- * Stubs VaultClient.vend / listMyExplicitGrants / entity.findInMyNamespace
5
- * and the S3 client so we cover the three acceptance criteria the unit
6
- * suite is responsible for (acceptance 6):
4
+ * Stubs the multi-tenant STS vend routes (`sts.vend` / `sts.vendSelf`),
5
+ * listMyExplicitGrants, entity.findInMyNamespace/get, and the S3 client.
6
+ * Coverage focus:
7
7
  *
8
- * 1. vend uses `purpose: 'browse'` (NOT `'sync'`) for both subcommands.
9
- * 2. `--out` refuses any destination under `<hqRoot>/companies/`.
10
- * 3. ACL-source classification: keys with a covering explicit grant →
11
- * `shared-with-you`; keys with no covering grant `role-bypass`.
8
+ * 1. Browse/cat vend via `/sts/vend` (company) and `/sts/vend-self`
9
+ * (personal) — NOT the legacy `POST /vend`, which is non-functional in
10
+ * multi-tenant prod (undefined BUCKET_ARN MalformedPolicyDocument).
11
+ * 2. Namespace translation: company vault keys are company-relative, so the
12
+ * S3 list/get prefix is the bucket-relative form while the CLI surface
13
+ * stays anchored at `companies/<slug>/`.
14
+ * 3. `--out` refuses any destination under `<hqRoot>/companies/`.
15
+ * 4. ACL-source classification over the company-relative key space.
12
16
  *
13
- * Plus the pure helpers (parseCompanySlugFromPath, classifyAclSource,
14
- * assertOutPathOutsideCompanies, formatBrowseTable).
17
+ * Plus the pure helpers (parseCompanySlugFromPath, toBucketRelative,
18
+ * toCompanyAnchored, classifyAclSource, assertOutPathOutsideCompanies,
19
+ * formatBrowseTable).
15
20
  */
16
21
 
17
22
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -25,13 +30,19 @@ import {
25
30
  classifyAclSource,
26
31
  formatBrowseTable,
27
32
  parseCompanySlugFromPath,
33
+ toBucketRelative,
34
+ toCompanyAnchored,
28
35
  runBrowse,
29
36
  runCat,
37
+ runSharedWithMe,
38
+ formatSharedWithMeTable,
39
+ type BrowseVendResult,
30
40
  type FilesBrowseS3Client,
31
41
  type FilesBrowseVaultClient,
42
+ type FilesSharedWithMeVaultClient,
32
43
  type S3ClientFactory,
33
44
  } from "./files-browse.js";
34
- import type { ExplicitGrant, VendResult } from "@indigoai-us/hq-cloud";
45
+ import type { ExplicitGrant } from "@indigoai-us/hq-cloud";
35
46
  import {
36
47
  ListObjectsV2Command,
37
48
  GetObjectCommand,
@@ -65,24 +76,17 @@ function fakeGrant(p: string): ExplicitGrant {
65
76
  };
66
77
  }
67
78
 
68
- function fakeVendResult(overrides: Partial<VendResult> = {}): VendResult {
79
+ function fakeStsVend(): BrowseVendResult {
69
80
  return {
70
81
  credentials: {
71
82
  accessKeyId: "ASIA-test",
72
83
  secretAccessKey: "secret-test",
73
84
  sessionToken: "session-test",
74
- expiration: new Date(Date.now() + 900_000).toISOString(),
75
85
  },
76
- paths: ["companies/indigo/"],
77
- operations: "read-only",
78
- purpose: "browse",
79
- policySize: 512,
80
- ...overrides,
81
86
  };
82
87
  }
83
88
 
84
89
  interface StubVaultOpts {
85
- vend?: VendResult;
86
90
  grants?: ExplicitGrant[];
87
91
  entity?:
88
92
  | { uid: string; slug: string; name?: string; bucketName?: string }
@@ -92,7 +96,8 @@ interface StubVaultOpts {
92
96
  function makeStubVaultClient(opts: StubVaultOpts = {}): {
93
97
  client: FilesBrowseVaultClient;
94
98
  spies: {
95
- vend: ReturnType<typeof vi.fn>;
99
+ stsVend: ReturnType<typeof vi.fn>;
100
+ vendSelf: ReturnType<typeof vi.fn>;
96
101
  listMyExplicitGrants: ReturnType<typeof vi.fn>;
97
102
  findInMyNamespace: ReturnType<typeof vi.fn>;
98
103
  };
@@ -106,7 +111,8 @@ function makeStubVaultClient(opts: StubVaultOpts = {}): {
106
111
  name: "Indigo",
107
112
  bucketName: "hq-vault-cmp-indigo",
108
113
  });
109
- const vend = vi.fn(async () => opts.vend ?? fakeVendResult());
114
+ const stsVend = vi.fn(async () => fakeStsVend());
115
+ const vendSelf = vi.fn(async () => fakeStsVend());
110
116
  const listMyExplicitGrants = vi.fn(async () => opts.grants ?? []);
111
117
  const findInMyNamespace = vi.fn(async () => entity);
112
118
  const get = vi.fn(async (uid: string) => {
@@ -114,11 +120,14 @@ function makeStubVaultClient(opts: StubVaultOpts = {}): {
114
120
  return entity;
115
121
  });
116
122
  const client: FilesBrowseVaultClient = {
117
- vend,
123
+ sts: { vend: stsVend, vendSelf },
118
124
  listMyExplicitGrants,
119
125
  entity: { get, findInMyNamespace },
120
126
  };
121
- return { client, spies: { vend, listMyExplicitGrants, findInMyNamespace } };
127
+ return {
128
+ client,
129
+ spies: { stsVend, vendSelf, listMyExplicitGrants, findInMyNamespace },
130
+ };
122
131
  }
123
132
 
124
133
  interface StubS3Opts {
@@ -171,36 +180,83 @@ describe("parseCompanySlugFromPath", () => {
171
180
  });
172
181
  });
173
182
 
183
+ // ── toBucketRelative / toCompanyAnchored (namespace translation) ────────────
184
+
185
+ describe("toBucketRelative", () => {
186
+ it("strips the companies/<slug>/ anchor to a company-relative key", () => {
187
+ expect(toBucketRelative("companies/indigo/knowledge/foo.md", "indigo")).toBe(
188
+ "knowledge/foo.md",
189
+ );
190
+ });
191
+
192
+ it("strips the anchor for a bare prefix (trailing slash preserved)", () => {
193
+ expect(toBucketRelative("companies/indigo/scratch/", "indigo")).toBe(
194
+ "scratch/",
195
+ );
196
+ });
197
+
198
+ it("yields empty string for the bare company root", () => {
199
+ expect(toBucketRelative("companies/indigo/", "indigo")).toBe("");
200
+ });
201
+
202
+ it("tolerates leading slashes", () => {
203
+ expect(toBucketRelative("/companies/indigo/x/y", "indigo")).toBe("x/y");
204
+ });
205
+
206
+ it("passes through a path that lacks the anchor (e.g. already relative)", () => {
207
+ expect(toBucketRelative("knowledge/foo.md", "indigo")).toBe(
208
+ "knowledge/foo.md",
209
+ );
210
+ });
211
+
212
+ it("does not strip a different company's anchor", () => {
213
+ expect(toBucketRelative("companies/acme/x", "indigo")).toBe(
214
+ "companies/acme/x",
215
+ );
216
+ });
217
+ });
218
+
219
+ describe("toCompanyAnchored", () => {
220
+ it("re-attaches the companies/<slug>/ anchor", () => {
221
+ expect(toCompanyAnchored("knowledge/foo.md", "indigo")).toBe(
222
+ "companies/indigo/knowledge/foo.md",
223
+ );
224
+ });
225
+
226
+ it("round-trips with toBucketRelative", () => {
227
+ const anchored = "companies/indigo/a/b/c.md";
228
+ expect(toCompanyAnchored(toBucketRelative(anchored, "indigo"), "indigo")).toBe(
229
+ anchored,
230
+ );
231
+ });
232
+ });
233
+
174
234
  // ── classifyAclSource ───────────────────────────────────────────────────────
175
235
 
176
236
  describe("classifyAclSource", () => {
177
- it("returns shared-with-you when any grant prefixes the key", () => {
178
- const grants = [fakeGrant("companies/indigo/scratch/")];
179
- expect(
180
- classifyAclSource("companies/indigo/scratch/foo.txt", grants),
181
- ).toBe("shared-with-you");
237
+ it("returns shared-with-you when any grant prefix covers the key", () => {
238
+ expect(classifyAclSource("scratch/foo.txt", ["scratch/"])).toBe(
239
+ "shared-with-you",
240
+ );
182
241
  });
183
242
 
184
- it("returns role-bypass when no grant covers the key", () => {
185
- const grants = [fakeGrant("companies/indigo/scratch/")];
186
- expect(
187
- classifyAclSource("companies/indigo/secrets/db.txt", grants),
188
- ).toBe("role-bypass");
243
+ it("returns role-bypass when no grant prefix covers the key", () => {
244
+ expect(classifyAclSource("secrets/db.txt", ["scratch/"])).toBe(
245
+ "role-bypass",
246
+ );
189
247
  });
190
248
 
191
249
  it("returns role-bypass on an empty grant list", () => {
192
- expect(classifyAclSource("companies/indigo/anything/x", [])).toBe(
193
- "role-bypass",
194
- );
250
+ expect(classifyAclSource("anything/x", [])).toBe("role-bypass");
195
251
  });
196
252
 
197
- it("matches against the first covering grant multiple grants are fine", () => {
198
- const grants = [
199
- fakeGrant("companies/other/"),
200
- fakeGrant("companies/indigo/scratch/"),
201
- ];
253
+ it("treats an empty-string prefix as a company-wide grant (shared)", () => {
254
+ expect(classifyAclSource("anything/x", [""])).toBe("shared-with-you");
255
+ });
256
+
257
+ it("matches against the first covering prefix — multiple are fine", () => {
202
258
  expect(
203
- classifyAclSource("companies/indigo/scratch/sub/y.bin", grants),
259
+ classifyAclSource("scratch/sub/y.bin", ["other/", "scratch/"]),
204
260
  ).toBe("shared-with-you");
205
261
  });
206
262
  });
@@ -297,9 +353,9 @@ describe("formatBrowseTable", () => {
297
353
  // ── runBrowse ───────────────────────────────────────────────────────────────
298
354
 
299
355
  describe("runBrowse", () => {
300
- it("vends with purpose='browse' (NOT 'sync') for the requested prefix", async () => {
356
+ it("vends via /sts/vend (company) and lists the company-relative prefix", async () => {
301
357
  const { client, spies } = makeStubVaultClient({});
302
- const { factory } = makeStubS3Factory({
358
+ const { factory, sendSpy } = makeStubS3Factory({
303
359
  listResponses: [{ Contents: [] }],
304
360
  });
305
361
  await runBrowse({
@@ -308,29 +364,32 @@ describe("runBrowse", () => {
308
364
  s3Factory: factory,
309
365
  region: "us-east-1",
310
366
  });
311
- expect(spies.vend).toHaveBeenCalledTimes(1);
312
- const arg = spies.vend.mock.calls[0][0];
313
- expect(arg.purpose).toBe("browse");
314
- expect(arg.purpose).not.toBe("sync");
315
- expect(arg.operations).toBe("read-only");
316
- expect(arg.paths).toEqual(["companies/indigo/scratch/"]);
367
+ // Vend through the multi-tenant STS route — NOT the legacy POST /vend.
368
+ expect(spies.stsVend).toHaveBeenCalledTimes(1);
369
+ expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
370
+ expect(spies.vendSelf).not.toHaveBeenCalled();
371
+ // S3 list prefix is company-relative (the bug: was anchored → 0 results).
372
+ const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
373
+ expect(listCmd.input.Prefix).toBe("scratch/");
317
374
  });
318
375
 
319
- it("paginates ListObjectsV2 fully and classifies each key's ACL source", async () => {
376
+ it("paginates ListObjectsV2 fully, classifies ACL, and re-anchors keys for display", async () => {
320
377
  const { client } = makeStubVaultClient({
321
- grants: [fakeGrant("companies/indigo/scratch/")],
378
+ // Real grants are glob/anchored; normalization folds this to "scratch/".
379
+ grants: [fakeGrant("companies/indigo/scratch/*")],
322
380
  });
323
381
  const { factory, sendSpy } = makeStubS3Factory({
382
+ // S3 keys are company-relative (no companies/<slug>/ prefix).
324
383
  listResponses: [
325
384
  {
326
385
  Contents: [
327
386
  {
328
- Key: "companies/indigo/scratch/a.txt",
387
+ Key: "scratch/a.txt",
329
388
  Size: 10,
330
389
  LastModified: new Date("2026-01-01T00:00:00Z"),
331
390
  },
332
391
  {
333
- Key: "companies/indigo/secrets/db.txt",
392
+ Key: "secrets/db.txt",
334
393
  Size: 20,
335
394
  LastModified: new Date("2026-01-02T00:00:00Z"),
336
395
  },
@@ -340,13 +399,13 @@ describe("runBrowse", () => {
340
399
  {
341
400
  Contents: [
342
401
  {
343
- Key: "companies/indigo/scratch/sub/b.bin",
402
+ Key: "scratch/sub/b.bin",
344
403
  Size: 30,
345
404
  LastModified: new Date("2026-01-03T00:00:00Z"),
346
405
  },
347
406
  // S3 directory marker — should be filtered out.
348
407
  {
349
- Key: "companies/indigo/scratch/empty/",
408
+ Key: "scratch/empty/",
350
409
  Size: 0,
351
410
  LastModified: new Date("2026-01-04T00:00:00Z"),
352
411
  },
@@ -362,6 +421,7 @@ describe("runBrowse", () => {
362
421
  });
363
422
  expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
364
423
  expect(result.rows).toHaveLength(3);
424
+ // Displayed keys are re-anchored to companies/<slug>/.
365
425
  const byKey = Object.fromEntries(result.rows.map((r) => [r.key, r]));
366
426
  expect(byKey["companies/indigo/scratch/a.txt"].aclSource).toBe(
367
427
  "shared-with-you",
@@ -391,13 +451,11 @@ describe("runBrowse", () => {
391
451
  // ── personalMode (hq-cli#26) ──────────────────────────────────────────────
392
452
  //
393
453
  // Personal mode resolves the entity via `entity.get(personalUid)` (skipping
394
- // the company-namespace lookup), omits the explicit-grants fetch, and tags
395
- // every row with `aclSource: "personal-vault"`. The path arg is bucket-
396
- // relative — companies/<slug>/ prefix is NOT required (and would be
397
- // incorrect, since the person bucket is owner-only with no companies/
398
- // subtree).
454
+ // the company-namespace lookup), vends via `/sts/vend-self`, omits the
455
+ // explicit-grants fetch, and tags every row `aclSource: "personal-vault"`.
456
+ // The path arg is bucket-relative — companies/<slug>/ prefix is NOT required.
399
457
 
400
- it("personalMode: resolves entity via entity.get(personalUid), skips namespace lookup", async () => {
458
+ it("personalMode: resolves via entity.get, vends /sts/vend-self, skips namespace + grants", async () => {
401
459
  const { client, spies } = makeStubVaultClient({
402
460
  entity: {
403
461
  uid: "prs_test",
@@ -428,10 +486,9 @@ describe("runBrowse", () => {
428
486
  expect(spies.findInMyNamespace).not.toHaveBeenCalled();
429
487
  // Grants graph is a company concept — must not be fetched.
430
488
  expect(spies.listMyExplicitGrants).not.toHaveBeenCalled();
431
- // Vend still issued for browse purpose, no policy difference.
432
- expect(spies.vend).toHaveBeenCalledWith(
433
- expect.objectContaining({ purpose: "browse", operations: "read-only" }),
434
- );
489
+ // Personal vends self, never the company route.
490
+ expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
491
+ expect(spies.stsVend).not.toHaveBeenCalled();
435
492
  });
436
493
 
437
494
  it("personalMode: empty pathPrefix lists the bucket root", async () => {
@@ -537,9 +594,9 @@ describe("runBrowse", () => {
537
594
  // ── runCat ──────────────────────────────────────────────────────────────────
538
595
 
539
596
  describe("runCat", () => {
540
- it("vends with purpose='browse' for a cat call", async () => {
597
+ it("vends via /sts/vend and GetObjects the company-relative key", async () => {
541
598
  const { client, spies } = makeStubVaultClient({});
542
- const { factory } = makeStubS3Factory({
599
+ const { factory, sendSpy } = makeStubS3Factory({
543
600
  getResponse: {
544
601
  Body: Readable.from(Buffer.from("hello world")),
545
602
  } as GetObjectCommandOutput,
@@ -554,8 +611,14 @@ describe("runCat", () => {
554
611
  hqRoot: tmpRoot,
555
612
  stdout: sink,
556
613
  });
557
- expect(spies.vend).toHaveBeenCalledTimes(1);
558
- expect(spies.vend.mock.calls[0][0].purpose).toBe("browse");
614
+ expect(spies.stsVend).toHaveBeenCalledTimes(1);
615
+ expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
616
+ // GetObject key is company-relative (anchor stripped).
617
+ const getCmd = sendSpy.mock.calls.find(
618
+ (c) => c[0] instanceof GetObjectCommand,
619
+ )?.[0] as GetObjectCommand;
620
+ expect(getCmd.input.Bucket).toBe("hq-vault-cmp-indigo");
621
+ expect(getCmd.input.Key).toBe("scratch/a.txt");
559
622
  });
560
623
 
561
624
  it("writes to --out when outside the companies tree and reports byte count", async () => {
@@ -598,7 +661,8 @@ describe("runCat", () => {
598
661
  ).rejects.toThrow(/Refusing to write/);
599
662
  // Critically: no vend was issued (guard runs first) and no S3 call
600
663
  // was made — failing closed is the whole point of the guard.
601
- expect(spies.vend).not.toHaveBeenCalled();
664
+ expect(spies.stsVend).not.toHaveBeenCalled();
665
+ expect(spies.vendSelf).not.toHaveBeenCalled();
602
666
  expect(sendSpy).not.toHaveBeenCalled();
603
667
  // And no file was written under the protected tree.
604
668
  expect(fs.existsSync(badOut)).toBe(false);
@@ -620,7 +684,7 @@ describe("runCat", () => {
620
684
 
621
685
  // ── personalMode (hq-cli#26) ──────────────────────────────────────────────
622
686
 
623
- it("personalMode: streams from the person bucket, no slug parse on the key", async () => {
687
+ it("personalMode: streams from the person bucket via /sts/vend-self, no slug parse", async () => {
624
688
  const { client, spies } = makeStubVaultClient({
625
689
  entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
626
690
  });
@@ -654,14 +718,10 @@ describe("runCat", () => {
654
718
 
655
719
  expect(result.destination.kind).toBe("stdout");
656
720
  expect(spies.findInMyNamespace).not.toHaveBeenCalled();
657
- // Vend issued against the bucket-relative key, browse purpose.
658
- expect(spies.vend).toHaveBeenCalledWith(
659
- expect.objectContaining({
660
- paths: [".claude/CLAUDE.md"],
661
- purpose: "browse",
662
- }),
663
- );
664
- // GetObject targeted the person bucket.
721
+ // Personal vends self, against the bucket-relative key.
722
+ expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
723
+ expect(spies.stsVend).not.toHaveBeenCalled();
724
+ // GetObject targeted the person bucket with the bucket-relative key.
665
725
  const getCmd = sendSpy.mock.calls.find(
666
726
  (c) => c[0] instanceof GetObjectCommand,
667
727
  )?.[0] as GetObjectCommand;
@@ -685,3 +745,89 @@ describe("runCat", () => {
685
745
  ).rejects.toThrow(/personalMode requires personalUid/);
686
746
  });
687
747
  });
748
+
749
+ // ── runSharedWithMe ──────────────────────────────────────────────────────────
750
+
751
+ function sharedWithMeClient(opts: {
752
+ memberships?: Array<{ companyUid: string }>;
753
+ grantsByCompany?: Record<string, ExplicitGrant[]>;
754
+ slugByUid?: Record<string, string>;
755
+ }): FilesSharedWithMeVaultClient {
756
+ return {
757
+ listMyMemberships: async () => opts.memberships ?? [],
758
+ listMyExplicitGrants: async (companyUid: string) =>
759
+ opts.grantsByCompany?.[companyUid] ?? [],
760
+ entity: {
761
+ get: async (uid: string) => ({
762
+ uid,
763
+ slug: opts.slugByUid?.[uid] ?? uid,
764
+ }),
765
+ },
766
+ };
767
+ }
768
+
769
+ describe("runSharedWithMe", () => {
770
+ it("lists explicit grants for a single company (companyUid supplied)", async () => {
771
+ const rows = await runSharedWithMe({
772
+ vaultClient: sharedWithMeClient({
773
+ grantsByCompany: {
774
+ cmp_indigo: [fakeGrant("knowledge/"), fakeGrant("reports/q3.pdf")],
775
+ },
776
+ }),
777
+ companyUid: "cmp_indigo",
778
+ companySlug: "indigo",
779
+ });
780
+ expect(rows).toEqual([
781
+ { companySlug: "indigo", path: "knowledge/", permission: "read", source: "person" },
782
+ { companySlug: "indigo", path: "reports/q3.pdf", permission: "read", source: "person" },
783
+ ]);
784
+ });
785
+
786
+ it("rolls up across every membership when no company is supplied", async () => {
787
+ const rows = await runSharedWithMe({
788
+ vaultClient: sharedWithMeClient({
789
+ memberships: [{ companyUid: "cmp_b" }, { companyUid: "cmp_a" }],
790
+ slugByUid: { cmp_a: "acme", cmp_b: "beta" },
791
+ grantsByCompany: {
792
+ cmp_a: [fakeGrant("docs/")],
793
+ cmp_b: [fakeGrant("shared/")],
794
+ },
795
+ }),
796
+ });
797
+ // Sorted by company slug, then path.
798
+ expect(rows.map((r) => [r.companySlug, r.path])).toEqual([
799
+ ["acme", "docs/"],
800
+ ["beta", "shared/"],
801
+ ]);
802
+ });
803
+
804
+ it("skips a company whose grant fetch throws (best-effort roll-up)", async () => {
805
+ const client: FilesSharedWithMeVaultClient = {
806
+ listMyMemberships: async () => [{ companyUid: "cmp_ok" }, { companyUid: "cmp_bad" }],
807
+ listMyExplicitGrants: async (uid: string) => {
808
+ if (uid === "cmp_bad") throw new Error("boom");
809
+ return [fakeGrant("ok/")];
810
+ },
811
+ entity: { get: async (uid: string) => ({ uid, slug: uid }) },
812
+ };
813
+ const rows = await runSharedWithMe({ vaultClient: client });
814
+ expect(rows).toEqual([
815
+ { companySlug: "cmp_ok", path: "ok/", permission: "read", source: "person" },
816
+ ]);
817
+ });
818
+
819
+ it("formats an empty result with the role-bypass caveat", () => {
820
+ const out = formatSharedWithMeTable([]);
821
+ expect(out).toContain("Nothing is explicitly shared with you");
822
+ expect(out).toContain("role-bypass");
823
+ });
824
+
825
+ it("formats rows as a table with company + path + permission + source", () => {
826
+ const out = formatSharedWithMeTable([
827
+ { companySlug: "indigo", path: "knowledge/", permission: "read", source: "person" },
828
+ ]);
829
+ expect(out).toContain("COMPANY");
830
+ expect(out).toContain("indigo");
831
+ expect(out).toContain("knowledge/");
832
+ });
833
+ });