@indigoai-us/hq-cli 5.28.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,31 +17,57 @@
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
  import { Command } from "commander";
32
37
  import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
33
- import { type VendResult, type ExplicitGrant } from "@indigoai-us/hq-cloud";
38
+ import { type ExplicitGrant } from "@indigoai-us/hq-cloud";
39
+ /** STS-vended credential set the browse/cat path consumes. */
40
+ export interface BrowseCredentials {
41
+ accessKeyId: string;
42
+ secretAccessKey: string;
43
+ sessionToken: string;
44
+ }
45
+ /** Minimal STS-vend response shape (both `/sts/vend` and `/sts/vend-self`). */
46
+ export interface BrowseVendResult {
47
+ credentials: BrowseCredentials;
48
+ }
34
49
  /**
35
50
  * Subset of `VaultClient` this command actually uses — exposed so tests
36
51
  * can stub vend + grants without standing up a real `VaultClient`.
52
+ *
53
+ * Browse/cat vend through the multi-tenant `/sts/vend` (company) and
54
+ * `/sts/vend-self` (personal) routes — NOT the legacy `POST /vend`, which
55
+ * assumes a single static bucket and is non-functional in multi-tenant
56
+ * production (it builds a policy against an undefined `BUCKET_ARN`, so STS
57
+ * rejects it with `MalformedPolicyDocument`). The STS routes resolve the
58
+ * caller's per-entity bucket and apply role/ACL scoping server-side.
37
59
  */
38
60
  export interface FilesBrowseVaultClient {
39
- vend(input: {
40
- paths: string[];
41
- operations: "read-only" | "read-write" | "staged-write";
42
- purpose: "sync" | "browse";
43
- duration?: number;
44
- }): Promise<VendResult>;
61
+ sts: {
62
+ vend(input: {
63
+ companyUid: string;
64
+ durationSeconds?: number;
65
+ }): Promise<BrowseVendResult>;
66
+ vendSelf(input: {
67
+ personUid: string;
68
+ durationSeconds?: number;
69
+ }): Promise<BrowseVendResult>;
70
+ };
45
71
  listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
46
72
  entity: {
47
73
  get(uid: string): Promise<{
@@ -96,15 +122,33 @@ export interface BrowseRow {
96
122
  */
97
123
  export declare function parseCompanySlugFromPath(prefix: string): string;
98
124
  /**
99
- * Classify a single S3 key against the caller's explicit-grant list. Any
100
- * grant whose `path` is a prefix of the key contributes `shared-with-you`;
101
- * otherwise the key is only visible via role-bypass on the vend call.
125
+ * Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
126
+ * the vault bucket actually stores. Company vault buckets are already
127
+ * company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
128
+ * `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
129
+ * the anchored form for user familiarity; we strip the anchor at the S3
130
+ * boundary. A path without the anchor (or personal-mode, bucket-relative
131
+ * paths) passes through unchanged.
132
+ */
133
+ export declare function toBucketRelative(pathOrPrefix: string, slug: string): string;
134
+ /**
135
+ * Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
136
+ * for display + `hq files cat` round-trip, so the CLI surface keeps speaking
137
+ * the anchored form the user passed in.
138
+ */
139
+ export declare function toCompanyAnchored(bucketRelKey: string, slug: string): string;
140
+ /**
141
+ * Classify a single company-relative S3 key against the caller's
142
+ * (already-normalized) explicit-grant prefixes. Any prefix that covers the
143
+ * key contributes `shared-with-you`; otherwise the key is visible only via
144
+ * the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
145
+ * prefix is a company-wide grant and matches everything.
102
146
  *
103
- * Grant paths and S3 keys live in the same canonical form ("companies/<slug>/…");
104
- * `coalescePrefixes` would shrink the list further but isn't required for
105
- * correctness`startsWith` already short-circuits on the first match.
147
+ * Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
148
+ * to company-relative `startsWith` prefixes via `grantPathToPrefix` before
149
+ * calling this keeping this helper pure and trivially testable.
106
150
  */
107
- export declare function classifyAclSource(key: string, grants: ExplicitGrant[]): AclSource;
151
+ export declare function classifyAclSource(bucketRelKey: string, grantPrefixes: string[]): AclSource;
108
152
  /**
109
153
  * Bright-line guard for `--out`: refuse to write any byte beneath
110
154
  * `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
@@ -151,15 +195,17 @@ export interface RunBrowseInput {
151
195
  }
152
196
  export interface RunBrowseResult {
153
197
  rows: BrowseRow[];
154
- vend: VendResult;
198
+ vend: BrowseVendResult;
155
199
  }
156
200
  /**
157
201
  * `hq files browse <path>` orchestrator.
158
202
  *
159
203
  * 1. Parse slug from prefix (or use override).
160
204
  * 2. Resolve companyUid + bucketName via VaultClient.entity.
161
- * 3. Vend with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
162
- * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
205
+ * 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
206
+ * `/sts/vend-self` personal) resolves the per-entity bucket + role/ACL.
207
+ * 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
208
+ * company-relative key space.
163
209
  * 5. Fetch explicit grants once, classify each key.
164
210
  *
165
211
  * Pure-ish: no console output, no process.exit — caller renders + exits.
@@ -197,7 +243,7 @@ export interface RunCatResult {
197
243
  kind: "file";
198
244
  absPath: string;
199
245
  };
200
- vend: VendResult;
246
+ vend: BrowseVendResult;
201
247
  }
202
248
  /**
203
249
  * `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
@@ -17,25 +17,30 @@
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
- !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]="102456e4-666a-5484-b432-7f0b73275818")}catch(e){}}();
37
+ !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]="e2f338ce-5871-59ad-ae56-99bd29e1711c")}catch(e){}}();
33
38
  import chalk from "chalk";
34
39
  import * as fs from "node:fs";
35
40
  import * as path from "node:path";
36
41
  import { pipeline } from "node:stream/promises";
37
42
  import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
38
- import { VaultClient, } from "@indigoai-us/hq-cloud";
43
+ import { VaultClient, grantPathToPrefix, } from "@indigoai-us/hq-cloud";
39
44
  import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
40
45
  import { getCompanyUid } from "../utils/vault-api.js";
41
46
  import { resolveCanonicalPersonUid } from "./cloud.js";
@@ -54,17 +59,43 @@ export function parseCompanySlugFromPath(prefix) {
54
59
  return parts[1];
55
60
  }
56
61
  /**
57
- * Classify a single S3 key against the caller's explicit-grant list. Any
58
- * grant whose `path` is a prefix of the key contributes `shared-with-you`;
59
- * otherwise the key is only visible via role-bypass on the vend call.
62
+ * Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
63
+ * the vault bucket actually stores. Company vault buckets are already
64
+ * company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
65
+ * `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
66
+ * the anchored form for user familiarity; we strip the anchor at the S3
67
+ * boundary. A path without the anchor (or personal-mode, bucket-relative
68
+ * paths) passes through unchanged.
69
+ */
70
+ export function toBucketRelative(pathOrPrefix, slug) {
71
+ const anchor = `companies/${slug}/`;
72
+ const normalized = pathOrPrefix.replace(/^\/+/, "");
73
+ return normalized.startsWith(anchor)
74
+ ? normalized.slice(anchor.length)
75
+ : normalized;
76
+ }
77
+ /**
78
+ * Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
79
+ * for display + `hq files cat` round-trip, so the CLI surface keeps speaking
80
+ * the anchored form the user passed in.
81
+ */
82
+ export function toCompanyAnchored(bucketRelKey, slug) {
83
+ return `companies/${slug}/${bucketRelKey}`;
84
+ }
85
+ /**
86
+ * Classify a single company-relative S3 key against the caller's
87
+ * (already-normalized) explicit-grant prefixes. Any prefix that covers the
88
+ * key contributes `shared-with-you`; otherwise the key is visible only via
89
+ * the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
90
+ * prefix is a company-wide grant and matches everything.
60
91
  *
61
- * Grant paths and S3 keys live in the same canonical form ("companies/<slug>/…");
62
- * `coalescePrefixes` would shrink the list further but isn't required for
63
- * correctness`startsWith` already short-circuits on the first match.
92
+ * Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
93
+ * to company-relative `startsWith` prefixes via `grantPathToPrefix` before
94
+ * calling this keeping this helper pure and trivially testable.
64
95
  */
65
- export function classifyAclSource(key, grants) {
66
- for (const g of grants) {
67
- if (g.path && key.startsWith(g.path))
96
+ export function classifyAclSource(bucketRelKey, grantPrefixes) {
97
+ for (const p of grantPrefixes) {
98
+ if (p === "" || bucketRelKey.startsWith(p))
68
99
  return "shared-with-you";
69
100
  }
70
101
  return "role-bypass";
@@ -116,8 +147,10 @@ export function formatBrowseTable(rows) {
116
147
  *
117
148
  * 1. Parse slug from prefix (or use override).
118
149
  * 2. Resolve companyUid + bucketName via VaultClient.entity.
119
- * 3. Vend with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
120
- * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
150
+ * 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
151
+ * `/sts/vend-self` personal) resolves the per-entity bucket + role/ACL.
152
+ * 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
153
+ * company-relative key space.
121
154
  * 5. Fetch explicit grants once, classify each key.
122
155
  *
123
156
  * Pure-ish: no console output, no process.exit — caller renders + exits.
@@ -127,10 +160,11 @@ export async function runBrowse(input) {
127
160
  // Branch by mode. Company mode parses slug from path and looks up by
128
161
  // namespace; personal mode resolves the entity directly by the supplied
129
162
  // person UID and skips the slug + grants machinery (a person bucket has
130
- // no grants graph — the owner is the only principal). The vend call is
131
- // identical for both modes once we have the entity in hand.
163
+ // no grants graph — the owner is the only principal).
132
164
  let bucket;
133
165
  let entityUid;
166
+ let slug;
167
+ let vend;
134
168
  if (personalMode) {
135
169
  if (!input.personalUid) {
136
170
  throw new Error("runBrowse: personalMode requires personalUid. Resolve via " +
@@ -142,9 +176,10 @@ export async function runBrowse(input) {
142
176
  }
143
177
  entityUid = entity.uid;
144
178
  bucket = entity.bucketName;
179
+ vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
145
180
  }
146
181
  else {
147
- const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
182
+ slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
148
183
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
149
184
  if (!entity) {
150
185
  throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
@@ -154,17 +189,11 @@ export async function runBrowse(input) {
154
189
  }
155
190
  entityUid = entity.uid;
156
191
  bucket = entity.bucketName;
192
+ // Multi-tenant vend: the server resolves this company's bucket + applies
193
+ // owner/admin role-bypass (full access) or member/guest ACL scoping. The
194
+ // legacy `POST /vend` is unused here — see FilesBrowseVaultClient docs.
195
+ vend = await vaultClient.sts.vend({ companyUid: entityUid });
157
196
  }
158
- // Distinct vend call from sync — `purpose: 'browse'` opts the request
159
- // into the role-bypass-allowed code path on the server (US-009). The
160
- // personal mode vends against the person entity which is owner-only by
161
- // construction; the vend response shape is identical so downstream
162
- // S3Client construction doesn't branch.
163
- const vend = await vaultClient.vend({
164
- paths: [pathPrefix],
165
- operations: "read-only",
166
- purpose: "browse",
167
- });
168
197
  const s3 = s3Factory({
169
198
  region,
170
199
  credentials: {
@@ -173,19 +202,26 @@ export async function runBrowse(input) {
173
202
  sessionToken: vend.credentials.sessionToken,
174
203
  },
175
204
  });
205
+ // Company vault keys are company-relative (no `companies/<slug>/` prefix), so
206
+ // translate the CLI's anchored prefix into the bucket-relative form before
207
+ // listing. Personal-mode paths are already bucket-relative.
208
+ const listPrefix = personalMode || slug === undefined
209
+ ? pathPrefix
210
+ : toBucketRelative(pathPrefix, slug);
176
211
  // Pull the caller's explicit-grant graph once so per-key classification
177
- // is O(grants) without N round-trips. Skipped in personal mode — the
178
- // grants graph is a company concept; a person bucket marks every row
179
- // as `"personal-vault"` directly.
180
- const grants = personalMode
212
+ // is O(grants) without N round-trips. Grant `path`s are normalized to
213
+ // company-relative `startsWith` prefixes (matching the bucket key space)
214
+ // via `grantPathToPrefix`. Skipped in personal mode — the grants graph is a
215
+ // company concept; a person bucket marks every row `"personal-vault"`.
216
+ const grantPrefixes = personalMode || slug === undefined
181
217
  ? []
182
- : await vaultClient.listMyExplicitGrants(entityUid);
218
+ : (await vaultClient.listMyExplicitGrants(entityUid)).map((g) => grantPathToPrefix(g.path, slug));
183
219
  const rows = [];
184
220
  let continuationToken;
185
221
  do {
186
222
  const resp = (await s3.send(new ListObjectsV2Command({
187
223
  Bucket: bucket,
188
- Prefix: pathPrefix,
224
+ Prefix: listPrefix,
189
225
  ContinuationToken: continuationToken,
190
226
  })));
191
227
  for (const obj of resp.Contents ?? []) {
@@ -194,11 +230,17 @@ export async function runBrowse(input) {
194
230
  // Skip S3 "directory marker" objects (0-byte, trailing slash).
195
231
  if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
196
232
  continue;
233
+ // `obj.Key` is company-relative. Classify in that space, then re-anchor
234
+ // for display so the CLI keeps speaking `companies/<slug>/...`.
197
235
  rows.push({
198
- key: obj.Key,
236
+ key: personalMode || slug === undefined
237
+ ? obj.Key
238
+ : toCompanyAnchored(obj.Key, slug),
199
239
  size: obj.Size ?? 0,
200
240
  lastModified: obj.LastModified,
201
- aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
241
+ aclSource: personalMode
242
+ ? "personal-vault"
243
+ : classifyAclSource(obj.Key, grantPrefixes),
202
244
  });
203
245
  }
204
246
  continuationToken = resp.NextContinuationToken ?? undefined;
@@ -221,6 +263,8 @@ export async function runCat(input) {
221
263
  // Same branch logic as runBrowse — see that function's doc-block for
222
264
  // the personal-vs-company rationale.
223
265
  let bucket;
266
+ let s3Key;
267
+ let vend;
224
268
  if (personalMode) {
225
269
  if (!input.personalUid) {
226
270
  throw new Error("runCat: personalMode requires personalUid. Resolve via " +
@@ -231,6 +275,8 @@ export async function runCat(input) {
231
275
  throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
232
276
  }
233
277
  bucket = entity.bucketName;
278
+ s3Key = key; // personal-mode keys are already bucket-relative
279
+ vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
234
280
  }
235
281
  else {
236
282
  const slug = input.companySlug ?? parseCompanySlugFromPath(key);
@@ -242,12 +288,10 @@ export async function runCat(input) {
242
288
  throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
243
289
  }
244
290
  bucket = entity.bucketName;
291
+ // Translate the anchored CLI key into the company-relative bucket key.
292
+ s3Key = toBucketRelative(key, slug);
293
+ vend = await vaultClient.sts.vend({ companyUid: entity.uid });
245
294
  }
246
- const vend = await vaultClient.vend({
247
- paths: [key],
248
- operations: "read-only",
249
- purpose: "browse",
250
- });
251
295
  const s3 = s3Factory({
252
296
  region,
253
297
  credentials: {
@@ -256,7 +300,7 @@ export async function runCat(input) {
256
300
  sessionToken: vend.credentials.sessionToken,
257
301
  },
258
302
  });
259
- const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
303
+ const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })));
260
304
  if (!resp.Body) {
261
305
  throw new Error(`GetObject for '${key}' returned no body.`);
262
306
  }
@@ -549,4 +593,4 @@ export function registerFilesBrowseCommands(filesCmd) {
549
593
  });
550
594
  }
551
595
  //# sourceMappingURL=files-browse.js.map
552
- //# debugId=102456e4-666a-5484-b432-7f0b73275818
596
+ //# debugId=e2f338ce-5871-59ad-ae56-99bd29e1711c
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.28.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.42.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,16 +30,19 @@ import {
25
30
  classifyAclSource,
26
31
  formatBrowseTable,
27
32
  parseCompanySlugFromPath,
33
+ toBucketRelative,
34
+ toCompanyAnchored,
28
35
  runBrowse,
29
36
  runCat,
30
37
  runSharedWithMe,
31
38
  formatSharedWithMeTable,
39
+ type BrowseVendResult,
32
40
  type FilesBrowseS3Client,
33
41
  type FilesBrowseVaultClient,
34
42
  type FilesSharedWithMeVaultClient,
35
43
  type S3ClientFactory,
36
44
  } from "./files-browse.js";
37
- import type { ExplicitGrant, VendResult } from "@indigoai-us/hq-cloud";
45
+ import type { ExplicitGrant } from "@indigoai-us/hq-cloud";
38
46
  import {
39
47
  ListObjectsV2Command,
40
48
  GetObjectCommand,
@@ -68,24 +76,17 @@ function fakeGrant(p: string): ExplicitGrant {
68
76
  };
69
77
  }
70
78
 
71
- function fakeVendResult(overrides: Partial<VendResult> = {}): VendResult {
79
+ function fakeStsVend(): BrowseVendResult {
72
80
  return {
73
81
  credentials: {
74
82
  accessKeyId: "ASIA-test",
75
83
  secretAccessKey: "secret-test",
76
84
  sessionToken: "session-test",
77
- expiration: new Date(Date.now() + 900_000).toISOString(),
78
85
  },
79
- paths: ["companies/indigo/"],
80
- operations: "read-only",
81
- purpose: "browse",
82
- policySize: 512,
83
- ...overrides,
84
86
  };
85
87
  }
86
88
 
87
89
  interface StubVaultOpts {
88
- vend?: VendResult;
89
90
  grants?: ExplicitGrant[];
90
91
  entity?:
91
92
  | { uid: string; slug: string; name?: string; bucketName?: string }
@@ -95,7 +96,8 @@ interface StubVaultOpts {
95
96
  function makeStubVaultClient(opts: StubVaultOpts = {}): {
96
97
  client: FilesBrowseVaultClient;
97
98
  spies: {
98
- vend: ReturnType<typeof vi.fn>;
99
+ stsVend: ReturnType<typeof vi.fn>;
100
+ vendSelf: ReturnType<typeof vi.fn>;
99
101
  listMyExplicitGrants: ReturnType<typeof vi.fn>;
100
102
  findInMyNamespace: ReturnType<typeof vi.fn>;
101
103
  };
@@ -109,7 +111,8 @@ function makeStubVaultClient(opts: StubVaultOpts = {}): {
109
111
  name: "Indigo",
110
112
  bucketName: "hq-vault-cmp-indigo",
111
113
  });
112
- const vend = vi.fn(async () => opts.vend ?? fakeVendResult());
114
+ const stsVend = vi.fn(async () => fakeStsVend());
115
+ const vendSelf = vi.fn(async () => fakeStsVend());
113
116
  const listMyExplicitGrants = vi.fn(async () => opts.grants ?? []);
114
117
  const findInMyNamespace = vi.fn(async () => entity);
115
118
  const get = vi.fn(async (uid: string) => {
@@ -117,11 +120,14 @@ function makeStubVaultClient(opts: StubVaultOpts = {}): {
117
120
  return entity;
118
121
  });
119
122
  const client: FilesBrowseVaultClient = {
120
- vend,
123
+ sts: { vend: stsVend, vendSelf },
121
124
  listMyExplicitGrants,
122
125
  entity: { get, findInMyNamespace },
123
126
  };
124
- return { client, spies: { vend, listMyExplicitGrants, findInMyNamespace } };
127
+ return {
128
+ client,
129
+ spies: { stsVend, vendSelf, listMyExplicitGrants, findInMyNamespace },
130
+ };
125
131
  }
126
132
 
127
133
  interface StubS3Opts {
@@ -174,36 +180,83 @@ describe("parseCompanySlugFromPath", () => {
174
180
  });
175
181
  });
176
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
+
177
234
  // ── classifyAclSource ───────────────────────────────────────────────────────
178
235
 
179
236
  describe("classifyAclSource", () => {
180
- it("returns shared-with-you when any grant prefixes the key", () => {
181
- const grants = [fakeGrant("companies/indigo/scratch/")];
182
- expect(
183
- classifyAclSource("companies/indigo/scratch/foo.txt", grants),
184
- ).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
+ );
185
241
  });
186
242
 
187
- it("returns role-bypass when no grant covers the key", () => {
188
- const grants = [fakeGrant("companies/indigo/scratch/")];
189
- expect(
190
- classifyAclSource("companies/indigo/secrets/db.txt", grants),
191
- ).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
+ );
192
247
  });
193
248
 
194
249
  it("returns role-bypass on an empty grant list", () => {
195
- expect(classifyAclSource("companies/indigo/anything/x", [])).toBe(
196
- "role-bypass",
197
- );
250
+ expect(classifyAclSource("anything/x", [])).toBe("role-bypass");
198
251
  });
199
252
 
200
- it("matches against the first covering grant multiple grants are fine", () => {
201
- const grants = [
202
- fakeGrant("companies/other/"),
203
- fakeGrant("companies/indigo/scratch/"),
204
- ];
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", () => {
205
258
  expect(
206
- classifyAclSource("companies/indigo/scratch/sub/y.bin", grants),
259
+ classifyAclSource("scratch/sub/y.bin", ["other/", "scratch/"]),
207
260
  ).toBe("shared-with-you");
208
261
  });
209
262
  });
@@ -300,9 +353,9 @@ describe("formatBrowseTable", () => {
300
353
  // ── runBrowse ───────────────────────────────────────────────────────────────
301
354
 
302
355
  describe("runBrowse", () => {
303
- 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 () => {
304
357
  const { client, spies } = makeStubVaultClient({});
305
- const { factory } = makeStubS3Factory({
358
+ const { factory, sendSpy } = makeStubS3Factory({
306
359
  listResponses: [{ Contents: [] }],
307
360
  });
308
361
  await runBrowse({
@@ -311,29 +364,32 @@ describe("runBrowse", () => {
311
364
  s3Factory: factory,
312
365
  region: "us-east-1",
313
366
  });
314
- expect(spies.vend).toHaveBeenCalledTimes(1);
315
- const arg = spies.vend.mock.calls[0][0];
316
- expect(arg.purpose).toBe("browse");
317
- expect(arg.purpose).not.toBe("sync");
318
- expect(arg.operations).toBe("read-only");
319
- 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/");
320
374
  });
321
375
 
322
- 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 () => {
323
377
  const { client } = makeStubVaultClient({
324
- grants: [fakeGrant("companies/indigo/scratch/")],
378
+ // Real grants are glob/anchored; normalization folds this to "scratch/".
379
+ grants: [fakeGrant("companies/indigo/scratch/*")],
325
380
  });
326
381
  const { factory, sendSpy } = makeStubS3Factory({
382
+ // S3 keys are company-relative (no companies/<slug>/ prefix).
327
383
  listResponses: [
328
384
  {
329
385
  Contents: [
330
386
  {
331
- Key: "companies/indigo/scratch/a.txt",
387
+ Key: "scratch/a.txt",
332
388
  Size: 10,
333
389
  LastModified: new Date("2026-01-01T00:00:00Z"),
334
390
  },
335
391
  {
336
- Key: "companies/indigo/secrets/db.txt",
392
+ Key: "secrets/db.txt",
337
393
  Size: 20,
338
394
  LastModified: new Date("2026-01-02T00:00:00Z"),
339
395
  },
@@ -343,13 +399,13 @@ describe("runBrowse", () => {
343
399
  {
344
400
  Contents: [
345
401
  {
346
- Key: "companies/indigo/scratch/sub/b.bin",
402
+ Key: "scratch/sub/b.bin",
347
403
  Size: 30,
348
404
  LastModified: new Date("2026-01-03T00:00:00Z"),
349
405
  },
350
406
  // S3 directory marker — should be filtered out.
351
407
  {
352
- Key: "companies/indigo/scratch/empty/",
408
+ Key: "scratch/empty/",
353
409
  Size: 0,
354
410
  LastModified: new Date("2026-01-04T00:00:00Z"),
355
411
  },
@@ -365,6 +421,7 @@ describe("runBrowse", () => {
365
421
  });
366
422
  expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
367
423
  expect(result.rows).toHaveLength(3);
424
+ // Displayed keys are re-anchored to companies/<slug>/.
368
425
  const byKey = Object.fromEntries(result.rows.map((r) => [r.key, r]));
369
426
  expect(byKey["companies/indigo/scratch/a.txt"].aclSource).toBe(
370
427
  "shared-with-you",
@@ -394,13 +451,11 @@ describe("runBrowse", () => {
394
451
  // ── personalMode (hq-cli#26) ──────────────────────────────────────────────
395
452
  //
396
453
  // Personal mode resolves the entity via `entity.get(personalUid)` (skipping
397
- // the company-namespace lookup), omits the explicit-grants fetch, and tags
398
- // every row with `aclSource: "personal-vault"`. The path arg is bucket-
399
- // relative — companies/<slug>/ prefix is NOT required (and would be
400
- // incorrect, since the person bucket is owner-only with no companies/
401
- // 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.
402
457
 
403
- 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 () => {
404
459
  const { client, spies } = makeStubVaultClient({
405
460
  entity: {
406
461
  uid: "prs_test",
@@ -431,10 +486,9 @@ describe("runBrowse", () => {
431
486
  expect(spies.findInMyNamespace).not.toHaveBeenCalled();
432
487
  // Grants graph is a company concept — must not be fetched.
433
488
  expect(spies.listMyExplicitGrants).not.toHaveBeenCalled();
434
- // Vend still issued for browse purpose, no policy difference.
435
- expect(spies.vend).toHaveBeenCalledWith(
436
- expect.objectContaining({ purpose: "browse", operations: "read-only" }),
437
- );
489
+ // Personal vends self, never the company route.
490
+ expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
491
+ expect(spies.stsVend).not.toHaveBeenCalled();
438
492
  });
439
493
 
440
494
  it("personalMode: empty pathPrefix lists the bucket root", async () => {
@@ -540,9 +594,9 @@ describe("runBrowse", () => {
540
594
  // ── runCat ──────────────────────────────────────────────────────────────────
541
595
 
542
596
  describe("runCat", () => {
543
- it("vends with purpose='browse' for a cat call", async () => {
597
+ it("vends via /sts/vend and GetObjects the company-relative key", async () => {
544
598
  const { client, spies } = makeStubVaultClient({});
545
- const { factory } = makeStubS3Factory({
599
+ const { factory, sendSpy } = makeStubS3Factory({
546
600
  getResponse: {
547
601
  Body: Readable.from(Buffer.from("hello world")),
548
602
  } as GetObjectCommandOutput,
@@ -557,8 +611,14 @@ describe("runCat", () => {
557
611
  hqRoot: tmpRoot,
558
612
  stdout: sink,
559
613
  });
560
- expect(spies.vend).toHaveBeenCalledTimes(1);
561
- 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");
562
622
  });
563
623
 
564
624
  it("writes to --out when outside the companies tree and reports byte count", async () => {
@@ -601,7 +661,8 @@ describe("runCat", () => {
601
661
  ).rejects.toThrow(/Refusing to write/);
602
662
  // Critically: no vend was issued (guard runs first) and no S3 call
603
663
  // was made — failing closed is the whole point of the guard.
604
- expect(spies.vend).not.toHaveBeenCalled();
664
+ expect(spies.stsVend).not.toHaveBeenCalled();
665
+ expect(spies.vendSelf).not.toHaveBeenCalled();
605
666
  expect(sendSpy).not.toHaveBeenCalled();
606
667
  // And no file was written under the protected tree.
607
668
  expect(fs.existsSync(badOut)).toBe(false);
@@ -623,7 +684,7 @@ describe("runCat", () => {
623
684
 
624
685
  // ── personalMode (hq-cli#26) ──────────────────────────────────────────────
625
686
 
626
- 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 () => {
627
688
  const { client, spies } = makeStubVaultClient({
628
689
  entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
629
690
  });
@@ -657,14 +718,10 @@ describe("runCat", () => {
657
718
 
658
719
  expect(result.destination.kind).toBe("stdout");
659
720
  expect(spies.findInMyNamespace).not.toHaveBeenCalled();
660
- // Vend issued against the bucket-relative key, browse purpose.
661
- expect(spies.vend).toHaveBeenCalledWith(
662
- expect.objectContaining({
663
- paths: [".claude/CLAUDE.md"],
664
- purpose: "browse",
665
- }),
666
- );
667
- // 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.
668
725
  const getCmd = sendSpy.mock.calls.find(
669
726
  (c) => c[0] instanceof GetObjectCommand,
670
727
  )?.[0] as GetObjectCommand;
@@ -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) {