@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.
@@ -17,16 +17,21 @@
17
17
  * `hq sync` owns, and writing a peeked object there would
18
18
  * silently re-import it into the sync envelope.
19
19
  *
20
- * Both subcommands vend via the new `purpose: 'browse'` path
21
- * (`VaultClient.vend`) shipped in hq-cloud US-009. The server treats that
22
- * purpose as the role-bypass-allowed surface sync vends NEVER widen, so
23
- * keeping browse on its own vend call is the acceptance-criteria-1
24
- * separation we need.
20
+ * Both subcommands vend through the multi-tenant STS routes
21
+ * `VaultClient.sts.vend` (`/sts/vend`, company) and `.sts.vendSelf`
22
+ * (`/sts/vend-self`, personal). These resolve the caller's per-entity bucket
23
+ * and apply role/ACL scoping server-side (owner/admin full access; member/
24
+ * guest per-prefix). The legacy `POST /vend` is deliberately NOT used: it
25
+ * assumes a single static `BUCKET_ARN` that is unset in multi-tenant prod, so
26
+ * it builds an invalid policy and STS rejects it (`MalformedPolicyDocument`).
25
27
  *
26
- * Cross-package note: depends on `VendInput`/`VendResult` + the
27
- * `VaultClient.vend` method from hq-cloud US-009 (commit 2f790c5).
28
- * hq-cli pins `@indigoai-us/hq-cloud` to `file:../hq-cloud` via
29
- * `pnpm.overrides` until that release ships to npm.
28
+ * Namespace note: company vault keys are company-relative (no
29
+ * `companies/<slug>/` prefix). The CLI speaks the anchored form for user
30
+ * familiarity and translates at the S3 boundary via `toBucketRelative` /
31
+ * `toCompanyAnchored`.
32
+ *
33
+ * Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
34
+ * methods and `grantPathToPrefix` from hq-cloud.
30
35
  */
31
36
 
32
37
  import { Command } from "commander";
@@ -46,7 +51,7 @@ import {
46
51
 
47
52
  import {
48
53
  VaultClient,
49
- type VendResult,
54
+ grantPathToPrefix,
50
55
  type ExplicitGrant,
51
56
  } from "@indigoai-us/hq-cloud";
52
57
 
@@ -61,17 +66,40 @@ import { resolveCanonicalPersonUid } from "./cloud.js";
61
66
 
62
67
  // ── Types ───────────────────────────────────────────────────────────────────
63
68
 
69
+ /** STS-vended credential set the browse/cat path consumes. */
70
+ export interface BrowseCredentials {
71
+ accessKeyId: string;
72
+ secretAccessKey: string;
73
+ sessionToken: string;
74
+ }
75
+
76
+ /** Minimal STS-vend response shape (both `/sts/vend` and `/sts/vend-self`). */
77
+ export interface BrowseVendResult {
78
+ credentials: BrowseCredentials;
79
+ }
80
+
64
81
  /**
65
82
  * Subset of `VaultClient` this command actually uses — exposed so tests
66
83
  * can stub vend + grants without standing up a real `VaultClient`.
84
+ *
85
+ * Browse/cat vend through the multi-tenant `/sts/vend` (company) and
86
+ * `/sts/vend-self` (personal) routes — NOT the legacy `POST /vend`, which
87
+ * assumes a single static bucket and is non-functional in multi-tenant
88
+ * production (it builds a policy against an undefined `BUCKET_ARN`, so STS
89
+ * rejects it with `MalformedPolicyDocument`). The STS routes resolve the
90
+ * caller's per-entity bucket and apply role/ACL scoping server-side.
67
91
  */
68
92
  export interface FilesBrowseVaultClient {
69
- vend(input: {
70
- paths: string[];
71
- operations: "read-only" | "read-write" | "staged-write";
72
- purpose: "sync" | "browse";
73
- duration?: number;
74
- }): Promise<VendResult>;
93
+ sts: {
94
+ vend(input: {
95
+ companyUid: string;
96
+ durationSeconds?: number;
97
+ }): Promise<BrowseVendResult>;
98
+ vendSelf(input: {
99
+ personUid: string;
100
+ durationSeconds?: number;
101
+ }): Promise<BrowseVendResult>;
102
+ };
75
103
  listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
76
104
  entity: {
77
105
  get(uid: string): Promise<{ uid: string; slug: string; name?: string; bucketName?: string }>;
@@ -138,20 +166,48 @@ export function parseCompanySlugFromPath(prefix: string): string {
138
166
  }
139
167
 
140
168
  /**
141
- * Classify a single S3 key against the caller's explicit-grant list. Any
142
- * grant whose `path` is a prefix of the key contributes `shared-with-you`;
143
- * otherwise the key is only visible via role-bypass on the vend call.
169
+ * Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
170
+ * the vault bucket actually stores. Company vault buckets are already
171
+ * company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
172
+ * `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
173
+ * the anchored form for user familiarity; we strip the anchor at the S3
174
+ * boundary. A path without the anchor (or personal-mode, bucket-relative
175
+ * paths) passes through unchanged.
176
+ */
177
+ export function toBucketRelative(pathOrPrefix: string, slug: string): string {
178
+ const anchor = `companies/${slug}/`;
179
+ const normalized = pathOrPrefix.replace(/^\/+/, "");
180
+ return normalized.startsWith(anchor)
181
+ ? normalized.slice(anchor.length)
182
+ : normalized;
183
+ }
184
+
185
+ /**
186
+ * Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
187
+ * for display + `hq files cat` round-trip, so the CLI surface keeps speaking
188
+ * the anchored form the user passed in.
189
+ */
190
+ export function toCompanyAnchored(bucketRelKey: string, slug: string): string {
191
+ return `companies/${slug}/${bucketRelKey}`;
192
+ }
193
+
194
+ /**
195
+ * Classify a single company-relative S3 key against the caller's
196
+ * (already-normalized) explicit-grant prefixes. Any prefix that covers the
197
+ * key contributes `shared-with-you`; otherwise the key is visible only via
198
+ * the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
199
+ * prefix is a company-wide grant and matches everything.
144
200
  *
145
- * Grant paths and S3 keys live in the same canonical form ("companies/<slug>/…");
146
- * `coalescePrefixes` would shrink the list further but isn't required for
147
- * correctness`startsWith` already short-circuits on the first match.
201
+ * Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
202
+ * to company-relative `startsWith` prefixes via `grantPathToPrefix` before
203
+ * calling this keeping this helper pure and trivially testable.
148
204
  */
149
205
  export function classifyAclSource(
150
- key: string,
151
- grants: ExplicitGrant[],
206
+ bucketRelKey: string,
207
+ grantPrefixes: string[],
152
208
  ): AclSource {
153
- for (const g of grants) {
154
- if (g.path && key.startsWith(g.path)) return "shared-with-you";
209
+ for (const p of grantPrefixes) {
210
+ if (p === "" || bucketRelKey.startsWith(p)) return "shared-with-you";
155
211
  }
156
212
  return "role-bypass";
157
213
  }
@@ -242,7 +298,7 @@ export interface RunBrowseInput {
242
298
 
243
299
  export interface RunBrowseResult {
244
300
  rows: BrowseRow[];
245
- vend: VendResult;
301
+ vend: BrowseVendResult;
246
302
  }
247
303
 
248
304
  /**
@@ -250,8 +306,10 @@ export interface RunBrowseResult {
250
306
  *
251
307
  * 1. Parse slug from prefix (or use override).
252
308
  * 2. Resolve companyUid + bucketName via VaultClient.entity.
253
- * 3. Vend with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
254
- * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
309
+ * 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
310
+ * `/sts/vend-self` personal) resolves the per-entity bucket + role/ACL.
311
+ * 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
312
+ * company-relative key space.
255
313
  * 5. Fetch explicit grants once, classify each key.
256
314
  *
257
315
  * Pure-ish: no console output, no process.exit — caller renders + exits.
@@ -262,10 +320,11 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
262
320
  // Branch by mode. Company mode parses slug from path and looks up by
263
321
  // namespace; personal mode resolves the entity directly by the supplied
264
322
  // person UID and skips the slug + grants machinery (a person bucket has
265
- // no grants graph — the owner is the only principal). The vend call is
266
- // identical for both modes once we have the entity in hand.
323
+ // no grants graph — the owner is the only principal).
267
324
  let bucket: string;
268
325
  let entityUid: string;
326
+ let slug: string | undefined;
327
+ let vend: BrowseVendResult;
269
328
  if (personalMode) {
270
329
  if (!input.personalUid) {
271
330
  throw new Error(
@@ -281,8 +340,9 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
281
340
  }
282
341
  entityUid = entity.uid;
283
342
  bucket = entity.bucketName;
343
+ vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
284
344
  } else {
285
- const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
345
+ slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
286
346
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
287
347
  if (!entity) {
288
348
  throw new Error(
@@ -296,19 +356,12 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
296
356
  }
297
357
  entityUid = entity.uid;
298
358
  bucket = entity.bucketName;
359
+ // Multi-tenant vend: the server resolves this company's bucket + applies
360
+ // owner/admin role-bypass (full access) or member/guest ACL scoping. The
361
+ // legacy `POST /vend` is unused here — see FilesBrowseVaultClient docs.
362
+ vend = await vaultClient.sts.vend({ companyUid: entityUid });
299
363
  }
300
364
 
301
- // Distinct vend call from sync — `purpose: 'browse'` opts the request
302
- // into the role-bypass-allowed code path on the server (US-009). The
303
- // personal mode vends against the person entity which is owner-only by
304
- // construction; the vend response shape is identical so downstream
305
- // S3Client construction doesn't branch.
306
- const vend = await vaultClient.vend({
307
- paths: [pathPrefix],
308
- operations: "read-only",
309
- purpose: "browse",
310
- });
311
-
312
365
  const s3 = s3Factory({
313
366
  region,
314
367
  credentials: {
@@ -318,13 +371,25 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
318
371
  },
319
372
  });
320
373
 
374
+ // Company vault keys are company-relative (no `companies/<slug>/` prefix), so
375
+ // translate the CLI's anchored prefix into the bucket-relative form before
376
+ // listing. Personal-mode paths are already bucket-relative.
377
+ const listPrefix =
378
+ personalMode || slug === undefined
379
+ ? pathPrefix
380
+ : toBucketRelative(pathPrefix, slug);
381
+
321
382
  // Pull the caller's explicit-grant graph once so per-key classification
322
- // is O(grants) without N round-trips. Skipped in personal mode — the
323
- // grants graph is a company concept; a person bucket marks every row
324
- // as `"personal-vault"` directly.
325
- const grants = personalMode
326
- ? ([] as ExplicitGrant[])
327
- : await vaultClient.listMyExplicitGrants(entityUid);
383
+ // is O(grants) without N round-trips. Grant `path`s are normalized to
384
+ // company-relative `startsWith` prefixes (matching the bucket key space)
385
+ // via `grantPathToPrefix`. Skipped in personal mode — the grants graph is a
386
+ // company concept; a person bucket marks every row `"personal-vault"`.
387
+ const grantPrefixes =
388
+ personalMode || slug === undefined
389
+ ? []
390
+ : (await vaultClient.listMyExplicitGrants(entityUid)).map((g) =>
391
+ grantPathToPrefix(g.path, slug as string),
392
+ );
328
393
 
329
394
  const rows: BrowseRow[] = [];
330
395
  let continuationToken: string | undefined;
@@ -332,7 +397,7 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
332
397
  const resp = (await s3.send(
333
398
  new ListObjectsV2Command({
334
399
  Bucket: bucket,
335
- Prefix: pathPrefix,
400
+ Prefix: listPrefix,
336
401
  ContinuationToken: continuationToken,
337
402
  }),
338
403
  )) as ListObjectsV2CommandOutput;
@@ -342,11 +407,18 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
342
407
  // Skip S3 "directory marker" objects (0-byte, trailing slash).
343
408
  if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
344
409
 
410
+ // `obj.Key` is company-relative. Classify in that space, then re-anchor
411
+ // for display so the CLI keeps speaking `companies/<slug>/...`.
345
412
  rows.push({
346
- key: obj.Key,
413
+ key:
414
+ personalMode || slug === undefined
415
+ ? obj.Key
416
+ : toCompanyAnchored(obj.Key, slug),
347
417
  size: obj.Size ?? 0,
348
418
  lastModified: obj.LastModified,
349
- aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
419
+ aclSource: personalMode
420
+ ? "personal-vault"
421
+ : classifyAclSource(obj.Key, grantPrefixes),
350
422
  });
351
423
  }
352
424
 
@@ -384,7 +456,7 @@ export interface RunCatInput {
384
456
  export interface RunCatResult {
385
457
  bytesWritten: number;
386
458
  destination: { kind: "stdout" } | { kind: "file"; absPath: string };
387
- vend: VendResult;
459
+ vend: BrowseVendResult;
388
460
  }
389
461
 
390
462
  /**
@@ -405,6 +477,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
405
477
  // Same branch logic as runBrowse — see that function's doc-block for
406
478
  // the personal-vs-company rationale.
407
479
  let bucket: string;
480
+ let s3Key: string;
481
+ let vend: BrowseVendResult;
408
482
  if (personalMode) {
409
483
  if (!input.personalUid) {
410
484
  throw new Error(
@@ -419,6 +493,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
419
493
  );
420
494
  }
421
495
  bucket = entity.bucketName;
496
+ s3Key = key; // personal-mode keys are already bucket-relative
497
+ vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
422
498
  } else {
423
499
  const slug = input.companySlug ?? parseCompanySlugFromPath(key);
424
500
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
@@ -433,14 +509,11 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
433
509
  );
434
510
  }
435
511
  bucket = entity.bucketName;
512
+ // Translate the anchored CLI key into the company-relative bucket key.
513
+ s3Key = toBucketRelative(key, slug);
514
+ vend = await vaultClient.sts.vend({ companyUid: entity.uid });
436
515
  }
437
516
 
438
- const vend = await vaultClient.vend({
439
- paths: [key],
440
- operations: "read-only",
441
- purpose: "browse",
442
- });
443
-
444
517
  const s3 = s3Factory({
445
518
  region,
446
519
  credentials: {
@@ -451,7 +524,7 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
451
524
  });
452
525
 
453
526
  const resp = (await s3.send(
454
- new GetObjectCommand({ Bucket: bucket, Key: key }),
527
+ new GetObjectCommand({ Bucket: bucket, Key: s3Key }),
455
528
  )) as GetObjectCommandOutput;
456
529
 
457
530
  if (!resp.Body) {
@@ -484,6 +557,125 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
484
557
  return { bytesWritten, destination: { kind: "stdout" }, vend };
485
558
  }
486
559
 
560
+ // ── shared-with-me ────────────────────────────────────────────────────────
561
+
562
+ /**
563
+ * Subset of `VaultClient` the `shared-with-me` orchestrator uses. No vend / S3
564
+ * — this is a pure read of the caller's explicit-grant graph, so it never
565
+ * touches the credential/browse vend surface.
566
+ */
567
+ export interface FilesSharedWithMeVaultClient {
568
+ listMyMemberships(): Promise<Array<{ companyUid: string }>>;
569
+ listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
570
+ entity: {
571
+ get(uid: string): Promise<{ uid: string; slug: string; name?: string }>;
572
+ };
573
+ }
574
+
575
+ export interface SharedWithMeRow {
576
+ companySlug: string;
577
+ /** Company-relative grant path (e.g. `knowledge/`, `reports/q3.pdf`). */
578
+ path: string;
579
+ permission: ExplicitGrant["permission"];
580
+ source: ExplicitGrant["source"];
581
+ }
582
+
583
+ export interface RunSharedWithMeInput {
584
+ vaultClient: FilesSharedWithMeVaultClient;
585
+ /**
586
+ * Scope to a single company by UID. When omitted, rolls up across every
587
+ * company the caller has a membership in (the cross-company "what's shared
588
+ * with me everywhere" view).
589
+ */
590
+ companyUid?: string;
591
+ /** Display slug for the single-company case (avoids an extra entity.get). */
592
+ companySlug?: string;
593
+ }
594
+
595
+ /**
596
+ * `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
597
+ * file-ACL grants — the canonical "what's been shared with me" surface.
598
+ * Role-bypass access (owner/admin) is intentionally excluded server-side by
599
+ * `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
600
+ *
601
+ * Pure data — no console output, no S3, no vend. The caller renders + exits.
602
+ */
603
+ export async function runSharedWithMe(
604
+ input: RunSharedWithMeInput,
605
+ ): Promise<SharedWithMeRow[]> {
606
+ const { vaultClient } = input;
607
+
608
+ // Resolve the (companyUid, slug) pairs to query. Single-company when a UID
609
+ // was supplied; otherwise fan out across every membership.
610
+ let targets: Array<{ uid: string; slug: string }>;
611
+ if (input.companyUid) {
612
+ targets = [{ uid: input.companyUid, slug: input.companySlug ?? input.companyUid }];
613
+ } else {
614
+ const memberships = await vaultClient.listMyMemberships();
615
+ targets = await Promise.all(
616
+ memberships.map(async (m) => {
617
+ try {
618
+ const ent = await vaultClient.entity.get(m.companyUid);
619
+ return { uid: m.companyUid, slug: ent.slug || m.companyUid };
620
+ } catch {
621
+ // Entity not visible — fall back to the UID as the display label
622
+ // rather than dropping the company's grants entirely.
623
+ return { uid: m.companyUid, slug: m.companyUid };
624
+ }
625
+ }),
626
+ );
627
+ }
628
+
629
+ const rows: SharedWithMeRow[] = [];
630
+ for (const t of targets) {
631
+ let grants: ExplicitGrant[];
632
+ try {
633
+ grants = await vaultClient.listMyExplicitGrants(t.uid);
634
+ } catch {
635
+ // A single company's grant fetch failing shouldn't sink the whole
636
+ // roll-up — skip it and continue (best-effort discovery view).
637
+ continue;
638
+ }
639
+ for (const g of grants) {
640
+ rows.push({
641
+ companySlug: t.slug,
642
+ path: g.path,
643
+ permission: g.permission,
644
+ source: g.source,
645
+ });
646
+ }
647
+ }
648
+
649
+ // Stable sort: company, then path — deterministic output for humans + tests.
650
+ rows.sort((a, b) =>
651
+ a.companySlug === b.companySlug
652
+ ? a.path.localeCompare(b.path)
653
+ : a.companySlug.localeCompare(b.companySlug),
654
+ );
655
+ return rows;
656
+ }
657
+
658
+ /**
659
+ * Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
660
+ */
661
+ export function formatSharedWithMeTable(rows: SharedWithMeRow[]): string {
662
+ if (rows.length === 0) {
663
+ return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
664
+ }
665
+ const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
666
+ const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
667
+ const widths = cols.map((c, i) =>
668
+ Math.max(c.length, ...data.map((row) => row[i].length)),
669
+ );
670
+ const renderRow = (row: string[]): string =>
671
+ row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
672
+ return [
673
+ chalk.bold(renderRow(cols)),
674
+ chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
675
+ ...data.map(renderRow),
676
+ ].join("\n");
677
+ }
678
+
487
679
  // ── CLI registration ────────────────────────────────────────────────────────
488
680
 
489
681
  const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
@@ -736,4 +928,42 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
736
928
  process.exit(1);
737
929
  }
738
930
  });
931
+
932
+ filesCmd
933
+ .command("shared-with-me")
934
+ .description(
935
+ "List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).",
936
+ )
937
+ .option(
938
+ "--company <slug>",
939
+ "Scope to a single company (defaults to a cross-company roll-up).",
940
+ )
941
+ .action(async (options: { company?: string }) => {
942
+ try {
943
+ const accessToken = await ensureCognitoToken();
944
+ const vaultConfig = buildVaultConfig(accessToken);
945
+ const client = new VaultClient(vaultConfig);
946
+
947
+ let companyUid: string | undefined;
948
+ if (options.company) {
949
+ // Confirm membership + resolve UID, same early-failure pattern as
950
+ // browse/cat. Roll-up mode skips this and fans out internally.
951
+ companyUid = await getCompanyUid(accessToken, options.company);
952
+ }
953
+
954
+ const rows = await runSharedWithMe({
955
+ vaultClient: client,
956
+ companyUid,
957
+ companySlug: options.company,
958
+ });
959
+
960
+ console.log(formatSharedWithMeTable(rows));
961
+ } catch (err) {
962
+ console.error(
963
+ chalk.red("Error:"),
964
+ err instanceof Error ? err.message : String(err),
965
+ );
966
+ process.exit(1);
967
+ }
968
+ });
739
969
  }
@@ -291,18 +291,24 @@ describe("resolveNarrowTarget", () => {
291
291
 
292
292
  describe("computeNarrowPlan", () => {
293
293
  it("coalesces grants and returns a partitioned plan", async () => {
294
+ // Files live on disk at their hq-root-relative path…
294
295
  writeFile("companies/acme/meetings/notes.md", "stays");
295
296
  writeFile("companies/acme/scratch/old.md", "clean orphan");
296
297
 
298
+ // …but the journal keys + grant paths are COMPANY-RELATIVE — the
299
+ // namespace the real server + engine use. (The old fixtures used
300
+ // full `companies/acme/...` grant paths, which never matched the
301
+ // company-relative keys buildNarrowPlan emits — masking the namespace
302
+ // bug this test now guards against.)
297
303
  const journal = journalFromFiles([
298
- { rel: "companies/acme/meetings/notes.md", contents: "stays" },
299
- { rel: "companies/acme/scratch/old.md", contents: "clean orphan" },
304
+ { rel: "meetings/notes.md", contents: "stays" },
305
+ { rel: "scratch/old.md", contents: "clean orphan" },
300
306
  ]);
301
307
 
302
308
  const { client } = makeStubClient({
303
309
  grants: [
304
- fakeGrant("companies/acme/meetings/"),
305
- fakeGrant("companies/acme/meetings/2026/"), // collapsed by coalesce
310
+ fakeGrant("meetings/"),
311
+ fakeGrant("meetings/2026/"), // collapsed by coalesce
306
312
  ],
307
313
  });
308
314
 
@@ -316,13 +322,67 @@ describe("computeNarrowPlan", () => {
316
322
  journalIO: journalIO.io,
317
323
  });
318
324
 
319
- expect(result.prospectivePrefixSet).toEqual([
320
- "companies/acme/meetings/",
325
+ expect(result.prospectivePrefixSet).toEqual(["meetings/"]);
326
+ expect(result.plan.totalStayingCount).toBe(1);
327
+ expect(result.plan.totalCleanCount).toBe(1);
328
+ expect(result.plan.totalDirtyCount).toBe(0);
329
+ });
330
+
331
+ it("normalizes real-world anchored + glob grant paths (grantPathToPrefix)", async () => {
332
+ writeFile("companies/acme/design-pack/logo.svg", "svg");
333
+ writeFile("companies/acme/scratch/old.md", "clean orphan");
334
+
335
+ const journal = journalFromFiles([
336
+ { rel: "design-pack/logo.svg", contents: "svg" },
337
+ { rel: "scratch/old.md", contents: "clean orphan" },
321
338
  ]);
339
+
340
+ // The exact messy shapes the live vault returns: full-anchored + glob,
341
+ // and slug-anchored + glob — neither startsWith-matches the
342
+ // company-relative local keys until grantPathToPrefix de-anchors them.
343
+ const { client } = makeStubClient({
344
+ grants: [
345
+ fakeGrant("companies/acme/design-pack/*"),
346
+ fakeGrant("acme/design-pack/2026/*"), // subsumed after normalize+coalesce
347
+ ],
348
+ });
349
+
350
+ const result = await computeNarrowPlan({
351
+ hqRoot: tmpRoot,
352
+ companySlug: "acme",
353
+ companyUid: "cmp_acme",
354
+ vaultClient: client,
355
+ journalIO: makeStubJournalIO(journal).io,
356
+ });
357
+
358
+ expect(result.prospectivePrefixSet).toEqual(["design-pack/"]);
359
+ // design-pack/logo.svg stays (covered); scratch/old.md is a clean orphan.
322
360
  expect(result.plan.totalStayingCount).toBe(1);
323
361
  expect(result.plan.totalCleanCount).toBe(1);
324
362
  expect(result.plan.totalDirtyCount).toBe(0);
325
363
  });
364
+
365
+ it("a wildcard '*' grant keeps everything (no orphans)", async () => {
366
+ writeFile("companies/acme/a.md", "a");
367
+ writeFile("companies/acme/sub/b.md", "b");
368
+
369
+ const { client } = makeStubClient({ grants: [fakeGrant("*")] });
370
+
371
+ const result = await computeNarrowPlan({
372
+ hqRoot: tmpRoot,
373
+ companySlug: "acme",
374
+ companyUid: "cmp_acme",
375
+ vaultClient: client,
376
+ journalIO: makeStubJournalIO(journalFromFiles([])).io,
377
+ });
378
+
379
+ // "*" → "" → guarded to [""] (covers everything) so narrowing keeps the
380
+ // whole tree rather than collapsing to "nothing" and proposing deletes.
381
+ expect(result.prospectivePrefixSet).toEqual([""]);
382
+ expect(result.plan.totalCleanCount).toBe(0);
383
+ expect(result.plan.totalDirtyCount).toBe(0);
384
+ expect(result.plan.totalStayingCount).toBe(2);
385
+ });
326
386
  });
327
387
 
328
388
  // ── applyNarrow ─────────────────────────────────────────────────────────────
@@ -45,6 +45,7 @@ import * as fs from "node:fs";
45
45
  import {
46
46
  VaultClient,
47
47
  coalescePrefixes,
48
+ grantPathToPrefix,
48
49
  readJournal,
49
50
  writeJournal,
50
51
  tombstoneEntry,
@@ -218,7 +219,20 @@ export async function computeNarrowPlan(
218
219
  const io = input.journalIO ?? realJournalIO;
219
220
 
220
221
  const grants = await vaultClient.listMyExplicitGrants(companyUid);
221
- const prospectivePrefixSet = coalescePrefixes(grants.map((g) => g.path));
222
+ // Normalize each grant into a company-relative, startsWith-friendly prefix
223
+ // (grantPathToPrefix, hq-cloud ≥5.42.0): real grants are anchored
224
+ // (`companies/<slug>/x/*`, `<slug>/x/*`) and glob-style (`x/*`, bare `*`),
225
+ // none of which startsWith-match the company-relative local-tree keys
226
+ // buildNarrowPlan emits. A wildcard grant normalizes to "" (everything);
227
+ // coalescePrefixes drops empties, so guard it explicitly to `[""]` (which
228
+ // isCoveredByAny treats as covering everything → nothing orphaned) rather
229
+ // than letting it collapse to "nothing" and propose deleting the tree.
230
+ const normalizedPrefixes = grants.map((g) =>
231
+ grantPathToPrefix(g.path, companySlug),
232
+ );
233
+ const prospectivePrefixSet = normalizedPrefixes.some((p) => p === "")
234
+ ? [""]
235
+ : coalescePrefixes(normalizedPrefixes);
222
236
  const journal = io.read(companySlug);
223
237
 
224
238
  const plan = buildNarrowPlan({