@indigoai-us/hq-cli 5.47.5 → 5.47.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,6 +98,15 @@ export type S3ClientFactory = (input: {
98
98
  sessionToken: string;
99
99
  };
100
100
  }) => FilesBrowseS3Client;
101
+ /**
102
+ * Factory for the COMPANY-mode browse client. Injectable for tests; the
103
+ * production implementation is `createCompanyPresignClient`. Keyed by
104
+ * `companyUid` (resolved by the orchestrator) — the access token is captured
105
+ * by the closure at the CLI layer.
106
+ */
107
+ export type CompanyBrowseClientFactory = (input: {
108
+ companyUid: string;
109
+ }) => FilesBrowseS3Client;
101
110
  /**
102
111
  * ACL provenance for a single listed key.
103
112
  * - `shared-with-you`: an explicit grant the caller holds covers the key.
@@ -190,12 +199,23 @@ export interface RunBrowseInput {
190
199
  */
191
200
  personalUid?: string;
192
201
  vaultClient: FilesBrowseVaultClient;
193
- s3Factory: S3ClientFactory;
202
+ /** PERSONAL mode: builds a direct-S3 client from vended creds. */
203
+ s3Factory?: S3ClientFactory;
204
+ /**
205
+ * COMPANY mode (HQ-59): builds the presign/list-backed client. Company
206
+ * browse no longer talks to S3 directly and does not vend STS creds.
207
+ */
208
+ companyClient?: CompanyBrowseClientFactory;
194
209
  region: string;
195
210
  }
196
211
  export interface RunBrowseResult {
197
212
  rows: BrowseRow[];
198
- vend: BrowseVendResult;
213
+ /**
214
+ * Present ONLY for the PERSONAL (vendSelf + direct S3) path. Company mode
215
+ * goes through the presign/list API and does not vend, so this is undefined
216
+ * there.
217
+ */
218
+ vend?: BrowseVendResult;
199
219
  }
200
220
  /**
201
221
  * `hq files browse <path>` orchestrator.
@@ -230,7 +250,10 @@ export interface RunCatInput {
230
250
  /** Canonical person-entity UID; required when `personalMode: true`. */
231
251
  personalUid?: string;
232
252
  vaultClient: FilesBrowseVaultClient;
233
- s3Factory: S3ClientFactory;
253
+ /** PERSONAL mode: builds a direct-S3 client from vended creds. */
254
+ s3Factory?: S3ClientFactory;
255
+ /** COMPANY mode (HQ-59): builds the presign/list-backed client. */
256
+ companyClient?: CompanyBrowseClientFactory;
234
257
  region: string;
235
258
  /** Destination stream for the stdout path. Injectable for tests. */
236
259
  stdout?: NodeJS.WritableStream;
@@ -243,7 +266,8 @@ export interface RunCatResult {
243
266
  kind: "file";
244
267
  absPath: string;
245
268
  };
246
- vend: BrowseVendResult;
269
+ /** Present ONLY for the PERSONAL (vendSelf + direct S3) path. */
270
+ vend?: BrowseVendResult;
247
271
  }
248
272
  /**
249
273
  * `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
@@ -308,7 +332,10 @@ export interface RunSearchInput {
308
332
  personalMode?: boolean;
309
333
  personalUid?: string;
310
334
  vaultClient: FilesBrowseVaultClient;
311
- s3Factory: S3ClientFactory;
335
+ /** PERSONAL mode: direct-S3 client factory. */
336
+ s3Factory?: S3ClientFactory;
337
+ /** COMPANY mode (HQ-59): presign/list client factory. */
338
+ companyClient?: CompanyBrowseClientFactory;
312
339
  region: string;
313
340
  }
314
341
  /**
@@ -348,7 +375,11 @@ export interface RunGetInput {
348
375
  hqRoot: string;
349
376
  companySlug?: string;
350
377
  vaultClient: FilesBrowseVaultClient;
351
- s3Factory: S3ClientFactory;
378
+ /**
379
+ * COMPANY mode (HQ-59): presign/list client factory. `get` is company-only,
380
+ * so it always goes through the API — no direct S3, no STS vend.
381
+ */
382
+ companyClient?: CompanyBrowseClientFactory;
352
383
  region: string;
353
384
  }
354
385
  export interface RunGetResult {
@@ -372,6 +403,15 @@ export interface RunGetResult {
372
403
  * HQ root itself, which is too broad to do implicitly.
373
404
  */
374
405
  export declare function runGet(input: RunGetInput): Promise<RunGetResult>;
406
+ /**
407
+ * Build a COMPANY-mode browse client backed by the list + presign API. The
408
+ * access token + companyUid are captured here; the orchestrator just calls
409
+ * `send(...)` as if it held an S3 client.
410
+ */
411
+ export declare function createCompanyPresignClient(input: {
412
+ token: string;
413
+ companyUid: string;
414
+ }): FilesBrowseS3Client;
375
415
  /**
376
416
  * Wire `hq files browse` + `hq files cat` onto an existing `files`
377
417
  * Commander group. `registerFilesCommand` in files.ts builds the group
@@ -34,15 +34,16 @@
34
34
  * methods and `grantPathToPrefix` from hq-cloud.
35
35
  */
36
36
 
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]="cad165f9-6445-5434-969b-e0705274aa56")}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]="76062756-52c6-58a0-a647-9fcc95550911")}catch(e){}}();
38
38
  import chalk from "chalk";
39
39
  import * as fs from "node:fs";
40
40
  import * as path from "node:path";
41
+ import { Readable } from "node:stream";
41
42
  import { pipeline } from "node:stream/promises";
42
43
  import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
43
44
  import { VaultClient, grantPathToPrefix, } from "@indigoai-us/hq-cloud";
44
45
  import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
45
- import { getCompanyUid } from "../utils/vault-api.js";
46
+ import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
46
47
  import { resolveCanonicalPersonUid } from "./cloud.js";
47
48
  // ── Pure helpers ────────────────────────────────────────────────────────────
48
49
  /**
@@ -142,6 +143,20 @@ export function formatBrowseTable(rows) {
142
143
  ];
143
144
  return lines.join("\n");
144
145
  }
146
+ /** Guard: the personal path needs an S3 factory. */
147
+ function requirePersonalS3Factory(f) {
148
+ if (!f) {
149
+ throw new Error("Personal-vault browse requires an s3Factory (direct-S3 client).");
150
+ }
151
+ return f;
152
+ }
153
+ /** Guard: the company path needs the presign/list client factory. */
154
+ function requireCompanyClient(f) {
155
+ if (!f) {
156
+ throw new Error("Company browse requires a companyClient (presign/list client).");
157
+ }
158
+ return f;
159
+ }
145
160
  /**
146
161
  * `hq files browse <path>` orchestrator.
147
162
  *
@@ -156,7 +171,7 @@ export function formatBrowseTable(rows) {
156
171
  * Pure-ish: no console output, no process.exit — caller renders + exits.
157
172
  */
158
173
  export async function runBrowse(input) {
159
- const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
174
+ const { pathPrefix, vaultClient, region, personalMode } = input;
160
175
  // Branch by mode. Company mode parses slug from path and looks up by
161
176
  // namespace; personal mode resolves the entity directly by the supplied
162
177
  // person UID and skips the slug + grants machinery (a person bucket has
@@ -165,6 +180,7 @@ export async function runBrowse(input) {
165
180
  let entityUid;
166
181
  let slug;
167
182
  let vend;
183
+ let s3;
168
184
  if (personalMode) {
169
185
  if (!input.personalUid) {
170
186
  throw new Error("runBrowse: personalMode requires personalUid. Resolve via " +
@@ -176,7 +192,17 @@ export async function runBrowse(input) {
176
192
  }
177
193
  entityUid = entity.uid;
178
194
  bucket = entity.bucketName;
195
+ // Personal vault keeps the direct-S3 path: vend self creds, build an S3
196
+ // client. (HQ-59 scopes the migration to COMPANY mode.)
179
197
  vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
198
+ s3 = requirePersonalS3Factory(input.s3Factory)({
199
+ region,
200
+ credentials: {
201
+ accessKeyId: vend.credentials.accessKeyId,
202
+ secretAccessKey: vend.credentials.secretAccessKey,
203
+ sessionToken: vend.credentials.sessionToken,
204
+ },
205
+ });
180
206
  }
181
207
  else {
182
208
  slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
@@ -189,19 +215,10 @@ export async function runBrowse(input) {
189
215
  }
190
216
  entityUid = entity.uid;
191
217
  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 });
218
+ // COMPANY mode (HQ-59): list/get go through the presign/list API, which
219
+ // enforces the same per-file ACLs server-side. No STS vend, no direct S3.
220
+ s3 = requireCompanyClient(input.companyClient)({ companyUid: entityUid });
196
221
  }
197
- const s3 = s3Factory({
198
- region,
199
- credentials: {
200
- accessKeyId: vend.credentials.accessKeyId,
201
- secretAccessKey: vend.credentials.secretAccessKey,
202
- sessionToken: vend.credentials.sessionToken,
203
- },
204
- });
205
222
  // Company vault keys are company-relative (no `companies/<slug>/` prefix), so
206
223
  // translate the CLI's anchored prefix into the bucket-relative form before
207
224
  // listing. Personal-mode paths are already bucket-relative.
@@ -253,7 +270,7 @@ export async function runBrowse(input) {
253
270
  * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
254
271
  */
255
272
  export async function runCat(input) {
256
- const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
273
+ const { key, vaultClient, region, hqRoot, personalMode } = input;
257
274
  // Acceptance 5: refuse BEFORE vending — no point pulling credentials
258
275
  // for a request we're already going to abort.
259
276
  let absOut;
@@ -265,6 +282,7 @@ export async function runCat(input) {
265
282
  let bucket;
266
283
  let s3Key;
267
284
  let vend;
285
+ let s3;
268
286
  if (personalMode) {
269
287
  if (!input.personalUid) {
270
288
  throw new Error("runCat: personalMode requires personalUid. Resolve via " +
@@ -277,6 +295,14 @@ export async function runCat(input) {
277
295
  bucket = entity.bucketName;
278
296
  s3Key = key; // personal-mode keys are already bucket-relative
279
297
  vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
298
+ s3 = requirePersonalS3Factory(input.s3Factory)({
299
+ region,
300
+ credentials: {
301
+ accessKeyId: vend.credentials.accessKeyId,
302
+ secretAccessKey: vend.credentials.secretAccessKey,
303
+ sessionToken: vend.credentials.sessionToken,
304
+ },
305
+ });
280
306
  }
281
307
  else {
282
308
  const slug = input.companySlug ?? parseCompanySlugFromPath(key);
@@ -290,16 +316,9 @@ export async function runCat(input) {
290
316
  bucket = entity.bucketName;
291
317
  // Translate the anchored CLI key into the company-relative bucket key.
292
318
  s3Key = toBucketRelative(key, slug);
293
- vend = await vaultClient.sts.vend({ companyUid: entity.uid });
319
+ // COMPANY mode (HQ-59): GetObject → presign GET. No STS vend, no direct S3.
320
+ s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
294
321
  }
295
- const s3 = s3Factory({
296
- region,
297
- credentials: {
298
- accessKeyId: vend.credentials.accessKeyId,
299
- secretAccessKey: vend.credentials.secretAccessKey,
300
- sessionToken: vend.credentials.sessionToken,
301
- },
302
- });
303
322
  const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })));
304
323
  if (!resp.Body) {
305
324
  throw new Error(`GetObject for '${key}' returned no body.`);
@@ -417,6 +436,7 @@ export async function runSearch(input) {
417
436
  personalUid: input.personalUid,
418
437
  vaultClient: input.vaultClient,
419
438
  s3Factory: input.s3Factory,
439
+ companyClient: input.companyClient,
420
440
  region: input.region,
421
441
  });
422
442
  const q = input.query.toLowerCase();
@@ -467,7 +487,7 @@ export function addPin(hqRoot, companySlug, prefix) {
467
487
  * HQ root itself, which is too broad to do implicitly.
468
488
  */
469
489
  export async function runGet(input) {
470
- const { path: vaultPath, vaultClient, s3Factory, region, hqRoot } = input;
490
+ const { path: vaultPath, vaultClient, hqRoot } = input;
471
491
  const slug = input.companySlug ?? parseCompanySlugFromPath(vaultPath);
472
492
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
473
493
  if (!entity) {
@@ -477,15 +497,9 @@ export async function runGet(input) {
477
497
  throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
478
498
  }
479
499
  const bucket = entity.bucketName;
480
- const vend = await vaultClient.sts.vend({ companyUid: entity.uid });
481
- const s3 = s3Factory({
482
- region,
483
- credentials: {
484
- accessKeyId: vend.credentials.accessKeyId,
485
- secretAccessKey: vend.credentials.secretAccessKey,
486
- sessionToken: vend.credentials.sessionToken,
487
- },
488
- });
500
+ // COMPANY mode (HQ-59): list + get through the presign/list API. No STS vend,
501
+ // no direct S3 — the server enforces the same per-file read ACLs.
502
+ const s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
489
503
  // Company-relative prefix to list/fetch (bucket keys carry no anchor).
490
504
  const bucketPrefix = toBucketRelative(vaultPath, slug);
491
505
  const keys = [];
@@ -544,6 +558,86 @@ export async function runGet(input) {
544
558
  }
545
559
  // ── CLI registration ────────────────────────────────────────────────────────
546
560
  const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
561
+ /**
562
+ * Build a COMPANY-mode browse client backed by the list + presign API. The
563
+ * access token + companyUid are captured here; the orchestrator just calls
564
+ * `send(...)` as if it held an S3 client.
565
+ */
566
+ export function createCompanyPresignClient(input) {
567
+ const { token, companyUid } = input;
568
+ async function listObjects(cmd) {
569
+ const prefix = cmd.input.Prefix ?? "";
570
+ const query = { company: companyUid };
571
+ if (prefix.length > 0)
572
+ query.prefix = prefix;
573
+ if (cmd.input.ContinuationToken)
574
+ query.cursor = cmd.input.ContinuationToken;
575
+ const res = await vaultApiFetch({ token, path: "/v1/files/list", query });
576
+ if (!res.ok) {
577
+ const body = (await res.json().catch(() => ({})));
578
+ throw new Error(body.message ?? body.error ?? `files list failed (${res.status})`);
579
+ }
580
+ const body = (await res.json());
581
+ return {
582
+ // Map the API's company-relative objects onto the ListObjectsV2 shape the
583
+ // orchestrators read (Key / Size / LastModified / ETag). Re-quote the
584
+ // etag to match S3's quoted form, in case any caller compares it.
585
+ Contents: (body.objects ?? []).map((o) => ({
586
+ Key: o.key,
587
+ Size: o.size,
588
+ LastModified: o.lastModified ? new Date(o.lastModified) : undefined,
589
+ ETag: o.etag != null ? `"${o.etag}"` : undefined,
590
+ })),
591
+ NextContinuationToken: body.cursor ?? undefined,
592
+ IsTruncated: Boolean(body.truncated),
593
+ // $metadata is required by the SDK output type; the orchestrators never
594
+ // read it, so a minimal stub is sufficient.
595
+ $metadata: {},
596
+ };
597
+ }
598
+ async function getObject(cmd) {
599
+ const key = cmd.input.Key;
600
+ const res = await vaultApiFetch({
601
+ token,
602
+ path: "/v1/files/presign",
603
+ method: "POST",
604
+ body: { company: companyUid, key, op: "get" },
605
+ });
606
+ if (!res.ok) {
607
+ const body = (await res.json().catch(() => ({})));
608
+ throw new Error(body.message ?? body.error ?? `presign failed (${res.status})`);
609
+ }
610
+ const body = (await res.json());
611
+ const first = body.results?.[0];
612
+ if (!first || !first.url) {
613
+ // Per-key denial/validation surfaces here (e.g. FILES_PRESIGN_FORBIDDEN).
614
+ throw new Error(first?.error ?? `No presigned URL returned for '${key}'`);
615
+ }
616
+ const dl = await fetch(first.url);
617
+ if (!dl.ok) {
618
+ throw new Error(`Failed to download '${key}' (HTTP ${dl.status})`);
619
+ }
620
+ // fetch() yields a web ReadableStream; the orchestrators consume Body as a
621
+ // Node Readable (body.on('data') + stream pipeline), so adapt it. An empty
622
+ // body (no stream) becomes an empty Readable.
623
+ const nodeBody = dl.body
624
+ ? Readable.fromWeb(dl.body)
625
+ : Readable.from([]);
626
+ return { Body: nodeBody, $metadata: {} };
627
+ }
628
+ function send(cmd) {
629
+ if (cmd instanceof ListObjectsV2Command)
630
+ return listObjects(cmd);
631
+ if (cmd instanceof GetObjectCommand)
632
+ return getObject(cmd);
633
+ throw new Error("createCompanyPresignClient: unsupported S3 command");
634
+ }
635
+ return { send };
636
+ }
637
+ /** Production company-mode client factory — closes over the caller's token. */
638
+ function makeCompanyPresignFactory(token) {
639
+ return ({ companyUid }) => createCompanyPresignClient({ token, companyUid });
640
+ }
547
641
  /**
548
642
  * Wire `hq files browse` + `hq files cat` onto an existing `files`
549
643
  * Commander group. `registerFilesCommand` in files.ts builds the group
@@ -622,7 +716,7 @@ export function registerFilesBrowseCommands(filesCmd) {
622
716
  pathPrefix: pathArg,
623
717
  companySlug: slug,
624
718
  vaultClient: client,
625
- s3Factory: defaultS3Factory,
719
+ companyClient: makeCompanyPresignFactory(accessToken),
626
720
  region: DEFAULT_COGNITO.region,
627
721
  });
628
722
  console.log(formatBrowseTable(result.rows));
@@ -699,7 +793,7 @@ export function registerFilesBrowseCommands(filesCmd) {
699
793
  hqRoot: options.hqRoot,
700
794
  companySlug: slug,
701
795
  vaultClient: client,
702
- s3Factory: defaultS3Factory,
796
+ companyClient: makeCompanyPresignFactory(accessToken),
703
797
  region: DEFAULT_COGNITO.region,
704
798
  });
705
799
  if (result.destination.kind === "file") {
@@ -781,7 +875,7 @@ export function registerFilesBrowseCommands(filesCmd) {
781
875
  query,
782
876
  companySlug: company,
783
877
  vaultClient: client,
784
- s3Factory: defaultS3Factory,
878
+ companyClient: makeCompanyPresignFactory(accessToken),
785
879
  region: DEFAULT_COGNITO.region,
786
880
  });
787
881
  console.log(formatBrowseTable(rows));
@@ -824,7 +918,7 @@ export function registerFilesBrowseCommands(filesCmd) {
824
918
  hqRoot: options.hqRoot,
825
919
  companySlug: slug,
826
920
  vaultClient: client,
827
- s3Factory: defaultS3Factory,
921
+ companyClient: makeCompanyPresignFactory(accessToken),
828
922
  region: DEFAULT_COGNITO.region,
829
923
  });
830
924
  console.error(chalk.green("✓"), `Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`);
@@ -839,4 +933,4 @@ export function registerFilesBrowseCommands(filesCmd) {
839
933
  });
840
934
  }
841
935
  //# sourceMappingURL=files-browse.js.map
842
- //# debugId=cad165f9-6445-5434-969b-e0705274aa56
936
+ //# debugId=76062756-52c6-58a0-a647-9fcc95550911
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.47.5",
3
+ "version": "5.47.7",
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": "^6.11.3",
18
+ "@indigoai-us/hq-cloud": "^6.11.5",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -42,11 +42,13 @@ import {
42
42
  pinFilePath,
43
43
  runSharedWithMe,
44
44
  formatSharedWithMeTable,
45
+ createCompanyPresignClient,
45
46
  type BrowseVendResult,
46
47
  type FilesBrowseS3Client,
47
48
  type FilesBrowseVaultClient,
48
49
  type FilesSharedWithMeVaultClient,
49
50
  type S3ClientFactory,
51
+ type CompanyBrowseClientFactory,
50
52
  } from "./files-browse.js";
51
53
  import type { ExplicitGrant } from "@indigoai-us/hq-cloud";
52
54
  import {
@@ -143,6 +145,14 @@ interface StubS3Opts {
143
145
 
144
146
  function makeStubS3Factory(opts: StubS3Opts): {
145
147
  factory: S3ClientFactory;
148
+ /**
149
+ * The SAME stub typed as a company-mode client factory. Company browse/cat/
150
+ * get/search now inject `companyClient` (the presign/list seam) instead of
151
+ * `s3Factory`; the stub ignores its factory arg either way, so one stub
152
+ * serves both. Personal-mode tests use `factory`; company-mode tests use
153
+ * `companyFactory`.
154
+ */
155
+ companyFactory: CompanyBrowseClientFactory;
146
156
  sendSpy: ReturnType<typeof vi.fn>;
147
157
  factorySpy: ReturnType<typeof vi.fn>;
148
158
  } {
@@ -163,7 +173,12 @@ function makeStubS3Factory(opts: StubS3Opts): {
163
173
  send: sendSpy,
164
174
  }) as unknown as FilesBrowseS3Client,
165
175
  );
166
- return { factory: factorySpy as unknown as S3ClientFactory, sendSpy, factorySpy };
176
+ return {
177
+ factory: factorySpy as unknown as S3ClientFactory,
178
+ companyFactory: factorySpy as unknown as CompanyBrowseClientFactory,
179
+ sendSpy,
180
+ factorySpy,
181
+ };
167
182
  }
168
183
 
169
184
  // ── parseCompanySlugFromPath ────────────────────────────────────────────────
@@ -359,22 +374,22 @@ describe("formatBrowseTable", () => {
359
374
  // ── runBrowse ───────────────────────────────────────────────────────────────
360
375
 
361
376
  describe("runBrowse", () => {
362
- it("vends via /sts/vend (company) and lists the company-relative prefix", async () => {
377
+ it("company mode lists via the presign/list client (no STS vend) on the company-relative prefix", async () => {
363
378
  const { client, spies } = makeStubVaultClient({});
364
- const { factory, sendSpy } = makeStubS3Factory({
379
+ const { companyFactory, sendSpy } = makeStubS3Factory({
365
380
  listResponses: [{ Contents: [] }],
366
381
  });
367
382
  await runBrowse({
368
383
  pathPrefix: "companies/indigo/scratch/",
369
384
  vaultClient: client,
370
- s3Factory: factory,
385
+ companyClient: companyFactory,
371
386
  region: "us-east-1",
372
387
  });
373
- // Vend through the multi-tenant STS routeNOT the legacy POST /vend.
374
- expect(spies.stsVend).toHaveBeenCalledTimes(1);
375
- expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
388
+ // HQ-59: company browse no longer vends STS credsit goes through the
389
+ // server-side list API, so NEITHER vend route is touched.
390
+ expect(spies.stsVend).not.toHaveBeenCalled();
376
391
  expect(spies.vendSelf).not.toHaveBeenCalled();
377
- // S3 list prefix is company-relative (the bug: was anchored → 0 results).
392
+ // List prefix is company-relative (the bug: was anchored → 0 results).
378
393
  const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
379
394
  expect(listCmd.input.Prefix).toBe("scratch/");
380
395
  });
@@ -384,8 +399,8 @@ describe("runBrowse", () => {
384
399
  // Real grants are glob/anchored; normalization folds this to "scratch/".
385
400
  grants: [fakeGrant("companies/indigo/scratch/*")],
386
401
  });
387
- const { factory, sendSpy } = makeStubS3Factory({
388
- // S3 keys are company-relative (no companies/<slug>/ prefix).
402
+ const { companyFactory, sendSpy } = makeStubS3Factory({
403
+ // List objects are company-relative (no companies/<slug>/ prefix).
389
404
  listResponses: [
390
405
  {
391
406
  Contents: [
@@ -422,7 +437,7 @@ describe("runBrowse", () => {
422
437
  const result = await runBrowse({
423
438
  pathPrefix: "companies/indigo/scratch/",
424
439
  vaultClient: client,
425
- s3Factory: factory,
440
+ companyClient: companyFactory,
426
441
  region: "us-east-1",
427
442
  });
428
443
  expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
@@ -600,9 +615,9 @@ describe("runBrowse", () => {
600
615
  // ── runCat ──────────────────────────────────────────────────────────────────
601
616
 
602
617
  describe("runCat", () => {
603
- it("vends via /sts/vend and GetObjects the company-relative key", async () => {
618
+ it("company mode fetches the company-relative key via presign (no STS vend)", async () => {
604
619
  const { client, spies } = makeStubVaultClient({});
605
- const { factory, sendSpy } = makeStubS3Factory({
620
+ const { companyFactory, sendSpy } = makeStubS3Factory({
606
621
  getResponse: {
607
622
  Body: Readable.from(Buffer.from("hello world")),
608
623
  } as GetObjectCommandOutput,
@@ -612,25 +627,25 @@ describe("runCat", () => {
612
627
  await runCat({
613
628
  key: "companies/indigo/scratch/a.txt",
614
629
  vaultClient: client,
615
- s3Factory: factory,
630
+ companyClient: companyFactory,
616
631
  region: "us-east-1",
617
632
  hqRoot: tmpRoot,
618
633
  stdout: sink,
619
634
  });
620
- expect(spies.stsVend).toHaveBeenCalledTimes(1);
621
- expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
635
+ // HQ-59: company cat goes through the presign API — no STS vend.
636
+ expect(spies.stsVend).not.toHaveBeenCalled();
637
+ expect(spies.vendSelf).not.toHaveBeenCalled();
622
638
  // GetObject key is company-relative (anchor stripped).
623
639
  const getCmd = sendSpy.mock.calls.find(
624
640
  (c) => c[0] instanceof GetObjectCommand,
625
641
  )?.[0] as GetObjectCommand;
626
- expect(getCmd.input.Bucket).toBe("hq-vault-cmp-indigo");
627
642
  expect(getCmd.input.Key).toBe("scratch/a.txt");
628
643
  });
629
644
 
630
645
  it("writes to --out when outside the companies tree and reports byte count", async () => {
631
646
  const { client } = makeStubVaultClient({});
632
647
  const payload = Buffer.from("safe-payload");
633
- const { factory } = makeStubS3Factory({
648
+ const { companyFactory } = makeStubS3Factory({
634
649
  getResponse: {
635
650
  Body: Readable.from(payload),
636
651
  } as GetObjectCommandOutput,
@@ -640,7 +655,7 @@ describe("runCat", () => {
640
655
  key: "companies/indigo/scratch/a.txt",
641
656
  out: outFile,
642
657
  vaultClient: client,
643
- s3Factory: factory,
658
+ companyClient: companyFactory,
644
659
  region: "us-east-1",
645
660
  hqRoot: tmpRoot,
646
661
  });
@@ -651,7 +666,7 @@ describe("runCat", () => {
651
666
 
652
667
  it("refuses --out under <hqRoot>/companies/ BEFORE vending (no leak)", async () => {
653
668
  const { client, spies } = makeStubVaultClient({});
654
- const { factory, sendSpy } = makeStubS3Factory({
669
+ const { companyFactory, sendSpy } = makeStubS3Factory({
655
670
  getResponse: { Body: Readable.from(Buffer.from("nope")) } as GetObjectCommandOutput,
656
671
  });
657
672
  const badOut = path.join(tmpRoot, "companies", "indigo", "leaked.txt");
@@ -660,13 +675,13 @@ describe("runCat", () => {
660
675
  key: "companies/indigo/scratch/a.txt",
661
676
  out: badOut,
662
677
  vaultClient: client,
663
- s3Factory: factory,
678
+ companyClient: companyFactory,
664
679
  region: "us-east-1",
665
680
  hqRoot: tmpRoot,
666
681
  }),
667
682
  ).rejects.toThrow(/Refusing to write/);
668
- // Critically: no vend was issued (guard runs first) and no S3 call
669
- // was made failing closed is the whole point of the guard.
683
+ // Critically: the guard runs FIRST no vend, and no list/presign/S3 call
684
+ // was made. Failing closed is the whole point of the guard.
670
685
  expect(spies.stsVend).not.toHaveBeenCalled();
671
686
  expect(spies.vendSelf).not.toHaveBeenCalled();
672
687
  expect(sendSpy).not.toHaveBeenCalled();
@@ -843,7 +858,7 @@ describe("runSharedWithMe", () => {
843
858
  describe("runSearch", () => {
844
859
  it("filters the company listing by case-insensitive substring on the key", async () => {
845
860
  const { client, spies } = makeStubVaultClient({});
846
- const { factory } = makeStubS3Factory({
861
+ const { companyFactory } = makeStubS3Factory({
847
862
  listResponses: [
848
863
  {
849
864
  Contents: [
@@ -858,20 +873,21 @@ describe("runSearch", () => {
858
873
  query: "roadmap", // lower-case query matches mixed-case key
859
874
  companySlug: "indigo",
860
875
  vaultClient: client,
861
- s3Factory: factory,
876
+ companyClient: companyFactory,
862
877
  region: "us-east-1",
863
878
  });
864
879
  // Keys are re-anchored for display; only the matching one survives.
865
880
  expect(rows.map((r) => r.key)).toEqual([
866
881
  "companies/indigo/knowledge/Roadmap.md",
867
882
  ]);
868
- // Vends via the multi-tenant route (inherited from runBrowse).
869
- expect(spies.stsVend).toHaveBeenCalledTimes(1);
883
+ // HQ-59: search lists via the presign/list API (inherited from runBrowse)
884
+ // no STS vend.
885
+ expect(spies.stsVend).not.toHaveBeenCalled();
870
886
  });
871
887
 
872
888
  it("returns an empty array when nothing matches", async () => {
873
889
  const { client } = makeStubVaultClient({});
874
- const { factory } = makeStubS3Factory({
890
+ const { companyFactory } = makeStubS3Factory({
875
891
  listResponses: [
876
892
  { Contents: [{ Key: "knowledge/a.md", Size: 1, LastModified: new Date() }] },
877
893
  ],
@@ -880,7 +896,7 @@ describe("runSearch", () => {
880
896
  query: "zzz-no-match",
881
897
  companySlug: "indigo",
882
898
  vaultClient: client,
883
- s3Factory: factory,
899
+ companyClient: companyFactory,
884
900
  region: "us-east-1",
885
901
  });
886
902
  expect(rows).toEqual([]);
@@ -922,7 +938,7 @@ describe("pin set", () => {
922
938
  function makeGetStubS3(opts: {
923
939
  listKeys: Array<{ Key: string; Size: number }>;
924
940
  bodyFor: (key: string) => Buffer;
925
- }): { factory: S3ClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
941
+ }): { companyFactory: CompanyBrowseClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
926
942
  const sendSpy = vi.fn(async (cmd: unknown) => {
927
943
  if (cmd instanceof ListObjectsV2Command) {
928
944
  return {
@@ -937,16 +953,17 @@ function makeGetStubS3(opts: {
937
953
  }
938
954
  throw new Error(`unexpected command: ${cmd}`);
939
955
  });
940
- const factory = vi.fn(
956
+ // `get` is company-only now, so the stub is the presign/list seam.
957
+ const companyFactory = vi.fn(
941
958
  () => ({ send: sendSpy }) as unknown as FilesBrowseS3Client,
942
- ) as unknown as S3ClientFactory;
943
- return { factory, sendSpy };
959
+ ) as unknown as CompanyBrowseClientFactory;
960
+ return { companyFactory, sendSpy };
944
961
  }
945
962
 
946
963
  describe("runGet", () => {
947
964
  it("materializes a prefix in-place under companies/<slug>/ and registers a pin", async () => {
948
965
  const { client, spies } = makeStubVaultClient({});
949
- const { factory, sendSpy } = makeGetStubS3({
966
+ const { companyFactory, sendSpy } = makeGetStubS3({
950
967
  listKeys: [
951
968
  { Key: "knowledge/a.md", Size: 3 },
952
969
  { Key: "knowledge/sub/b.md", Size: 3 },
@@ -959,12 +976,12 @@ describe("runGet", () => {
959
976
  hqRoot: tmpRoot,
960
977
  companySlug: "indigo",
961
978
  vaultClient: client,
962
- s3Factory: factory,
979
+ companyClient: companyFactory,
963
980
  region: "us-east-1",
964
981
  });
965
982
 
966
- // Vends via the multi-tenant company route.
967
- expect(spies.stsVend).toHaveBeenCalledWith({ companyUid: "cmp_indigo" });
983
+ // HQ-59: get materializes through the presign/list API — no STS vend.
984
+ expect(spies.stsVend).not.toHaveBeenCalled();
968
985
  expect(result.filesWritten).toBe(2);
969
986
 
970
987
  // Files landed in place under companies/<slug>/.
@@ -995,7 +1012,7 @@ describe("runGet", () => {
995
1012
  it("--into writes outside companies/ and registers NO pin", async () => {
996
1013
  const into = path.join(tmpRoot, "extract");
997
1014
  const { client } = makeStubVaultClient({});
998
- const { factory } = makeGetStubS3({
1015
+ const { companyFactory } = makeGetStubS3({
999
1016
  listKeys: [{ Key: "knowledge/a.md", Size: 3 }],
1000
1017
  bodyFor: () => Buffer.from("AAA"),
1001
1018
  });
@@ -1006,7 +1023,7 @@ describe("runGet", () => {
1006
1023
  hqRoot: tmpRoot,
1007
1024
  companySlug: "indigo",
1008
1025
  vaultClient: client,
1009
- s3Factory: factory,
1026
+ companyClient: companyFactory,
1010
1027
  region: "us-east-1",
1011
1028
  });
1012
1029
 
@@ -1019,7 +1036,7 @@ describe("runGet", () => {
1019
1036
 
1020
1037
  it("throws when no objects exist under the path", async () => {
1021
1038
  const { client } = makeStubVaultClient({});
1022
- const { factory } = makeGetStubS3({
1039
+ const { companyFactory } = makeGetStubS3({
1023
1040
  listKeys: [],
1024
1041
  bodyFor: () => Buffer.from(""),
1025
1042
  });
@@ -1029,7 +1046,7 @@ describe("runGet", () => {
1029
1046
  hqRoot: tmpRoot,
1030
1047
  companySlug: "indigo",
1031
1048
  vaultClient: client,
1032
- s3Factory: factory,
1049
+ companyClient: companyFactory,
1033
1050
  region: "us-east-1",
1034
1051
  }),
1035
1052
  ).rejects.toThrow(/No objects under/);
@@ -1083,3 +1100,150 @@ describe("--company parent binding (optsWithGlobals)", () => {
1083
1100
  expect(local).toBeUndefined();
1084
1101
  });
1085
1102
  });
1103
+
1104
+ // ── createCompanyPresignClient (HQ-59 list/presign translation) ─────────────
1105
+ //
1106
+ // The company-mode browse client. Verifies it translates the S3-shaped
1107
+ // commands the orchestrators issue into the server-side list/presign API:
1108
+ // - ListObjectsV2Command → GET /v1/files/list (company-relative objects)
1109
+ // - GetObjectCommand → POST /v1/files/presign → fetch(url) → Body
1110
+ // fetch is the single network seam (vaultApiFetch uses it; the presigned
1111
+ // download uses it), so we spy on globalThis.fetch and route by URL.
1112
+
1113
+ describe("createCompanyPresignClient", () => {
1114
+ let fetchSpy: ReturnType<typeof vi.spyOn>;
1115
+
1116
+ beforeEach(() => {
1117
+ fetchSpy = vi.spyOn(globalThis, "fetch");
1118
+ });
1119
+ afterEach(() => {
1120
+ fetchSpy.mockRestore();
1121
+ });
1122
+
1123
+ function json(status: number, body: unknown): Response {
1124
+ return new Response(JSON.stringify(body), {
1125
+ status,
1126
+ headers: { "content-type": "application/json" },
1127
+ });
1128
+ }
1129
+
1130
+ it("ListObjectsV2 → GET /v1/files/list, mapping objects + cursor onto the S3 shape", async () => {
1131
+ fetchSpy.mockResolvedValueOnce(
1132
+ json(200, {
1133
+ prefix: "scratch/",
1134
+ objects: [
1135
+ {
1136
+ key: "scratch/a.txt",
1137
+ size: 12,
1138
+ lastModified: "2026-01-01T00:00:00.000Z",
1139
+ etag: "abc123",
1140
+ permission: "read",
1141
+ },
1142
+ ],
1143
+ cursor: "next-page",
1144
+ truncated: true,
1145
+ }),
1146
+ );
1147
+
1148
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1149
+ const out = await client.send(
1150
+ new ListObjectsV2Command({
1151
+ Bucket: "ignored",
1152
+ Prefix: "scratch/",
1153
+ ContinuationToken: undefined,
1154
+ }),
1155
+ );
1156
+
1157
+ // Request: GET to /v1/files/list with company + prefix query, bearer token.
1158
+ const [url, init] = fetchSpy.mock.calls[0];
1159
+ expect(String(url)).toContain("/v1/files/list");
1160
+ expect(String(url)).toContain("company=cmp_indigo");
1161
+ expect(String(url)).toContain("prefix=scratch%2F");
1162
+ expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer tok");
1163
+
1164
+ // Response mapped onto the ListObjectsV2 shape the orchestrators read.
1165
+ expect(out.Contents).toHaveLength(1);
1166
+ expect(out.Contents?.[0].Key).toBe("scratch/a.txt");
1167
+ expect(out.Contents?.[0].Size).toBe(12);
1168
+ expect(out.Contents?.[0].ETag).toBe('"abc123"');
1169
+ expect(out.Contents?.[0].LastModified?.toISOString()).toBe("2026-01-01T00:00:00.000Z");
1170
+ expect(out.NextContinuationToken).toBe("next-page");
1171
+ expect(out.IsTruncated).toBe(true);
1172
+ });
1173
+
1174
+ it("ListObjectsV2 forwards the ContinuationToken as the cursor query param", async () => {
1175
+ fetchSpy.mockResolvedValueOnce(json(200, { objects: [], cursor: null, truncated: false }));
1176
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1177
+ await client.send(new ListObjectsV2Command({ Bucket: "b", Prefix: "k/", ContinuationToken: "page2" }));
1178
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("cursor=page2");
1179
+ });
1180
+
1181
+ it("ListObjectsV2 throws on a non-2xx list response", async () => {
1182
+ fetchSpy.mockResolvedValueOnce(json(403, { error: "Forbidden", code: "X" }));
1183
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1184
+ await expect(
1185
+ client.send(new ListObjectsV2Command({ Bucket: "b", Prefix: "k/" })),
1186
+ ).rejects.toThrow(/Forbidden/);
1187
+ });
1188
+
1189
+ it("GetObject → POST /v1/files/presign then fetches the URL and returns a Body", async () => {
1190
+ // 1) presign POST → returns a URL; 2) the URL fetch → returns the bytes.
1191
+ fetchSpy
1192
+ .mockResolvedValueOnce(
1193
+ json(200, { results: [{ key: "scratch/a.txt", op: "get", url: "https://s3.example/presigned" }] }),
1194
+ )
1195
+ .mockResolvedValueOnce(new Response("file-bytes-here", { status: 200 }));
1196
+
1197
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1198
+ const out = await client.send(
1199
+ new GetObjectCommand({ Bucket: "ignored", Key: "scratch/a.txt" }),
1200
+ );
1201
+
1202
+ // First call: POST presign with the right body.
1203
+ const [presignUrl, presignInit] = fetchSpy.mock.calls[0];
1204
+ expect(String(presignUrl)).toContain("/v1/files/presign");
1205
+ expect(presignInit?.method).toBe("POST");
1206
+ expect(JSON.parse(presignInit?.body as string)).toEqual({
1207
+ company: "cmp_indigo",
1208
+ key: "scratch/a.txt",
1209
+ op: "get",
1210
+ });
1211
+ // Second call: fetch the presigned URL.
1212
+ expect(String(fetchSpy.mock.calls[1][0])).toBe("https://s3.example/presigned");
1213
+
1214
+ // Body is a readable stream of the downloaded bytes.
1215
+ const chunks: Buffer[] = [];
1216
+ for await (const c of out.Body as unknown as AsyncIterable<Buffer>) {
1217
+ chunks.push(Buffer.from(c));
1218
+ }
1219
+ expect(Buffer.concat(chunks).toString("utf-8")).toBe("file-bytes-here");
1220
+ });
1221
+
1222
+ it("GetObject throws the per-key error when presign denies the key (no URL)", async () => {
1223
+ fetchSpy.mockResolvedValueOnce(
1224
+ json(200, {
1225
+ results: [
1226
+ { key: "secret/x", op: "get", error: "Caller lacks read permission on 'secret/x'", code: "FILES_PRESIGN_FORBIDDEN" },
1227
+ ],
1228
+ }),
1229
+ );
1230
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1231
+ await expect(
1232
+ client.send(new GetObjectCommand({ Bucket: "b", Key: "secret/x" })),
1233
+ ).rejects.toThrow(/lacks read permission/);
1234
+ // The denial short-circuits BEFORE any download fetch.
1235
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
1236
+ });
1237
+
1238
+ it("GetObject throws when the presigned download itself fails", async () => {
1239
+ fetchSpy
1240
+ .mockResolvedValueOnce(
1241
+ json(200, { results: [{ key: "k", op: "get", url: "https://s3.example/x" }] }),
1242
+ )
1243
+ .mockResolvedValueOnce(new Response("nope", { status: 500 }));
1244
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1245
+ await expect(
1246
+ client.send(new GetObjectCommand({ Bucket: "b", Key: "k" })),
1247
+ ).rejects.toThrow(/Failed to download 'k'/);
1248
+ });
1249
+ });
@@ -61,7 +61,7 @@ import {
61
61
  ensureCognitoToken,
62
62
  buildVaultConfig,
63
63
  } from "../utils/cognito-session.js";
64
- import { getCompanyUid } from "../utils/vault-api.js";
64
+ import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
65
65
  import { resolveCanonicalPersonUid } from "./cloud.js";
66
66
 
67
67
  // ── Types ───────────────────────────────────────────────────────────────────
@@ -128,6 +128,16 @@ export type S3ClientFactory = (input: {
128
128
  };
129
129
  }) => FilesBrowseS3Client;
130
130
 
131
+ /**
132
+ * Factory for the COMPANY-mode browse client. Injectable for tests; the
133
+ * production implementation is `createCompanyPresignClient`. Keyed by
134
+ * `companyUid` (resolved by the orchestrator) — the access token is captured
135
+ * by the closure at the CLI layer.
136
+ */
137
+ export type CompanyBrowseClientFactory = (input: {
138
+ companyUid: string;
139
+ }) => FilesBrowseS3Client;
140
+
131
141
  /**
132
142
  * ACL provenance for a single listed key.
133
143
  * - `shared-with-you`: an explicit grant the caller holds covers the key.
@@ -292,13 +302,46 @@ export interface RunBrowseInput {
292
302
  */
293
303
  personalUid?: string;
294
304
  vaultClient: FilesBrowseVaultClient;
295
- s3Factory: S3ClientFactory;
305
+ /** PERSONAL mode: builds a direct-S3 client from vended creds. */
306
+ s3Factory?: S3ClientFactory;
307
+ /**
308
+ * COMPANY mode (HQ-59): builds the presign/list-backed client. Company
309
+ * browse no longer talks to S3 directly and does not vend STS creds.
310
+ */
311
+ companyClient?: CompanyBrowseClientFactory;
296
312
  region: string;
297
313
  }
298
314
 
299
315
  export interface RunBrowseResult {
300
316
  rows: BrowseRow[];
301
- vend: BrowseVendResult;
317
+ /**
318
+ * Present ONLY for the PERSONAL (vendSelf + direct S3) path. Company mode
319
+ * goes through the presign/list API and does not vend, so this is undefined
320
+ * there.
321
+ */
322
+ vend?: BrowseVendResult;
323
+ }
324
+
325
+ /** Guard: the personal path needs an S3 factory. */
326
+ function requirePersonalS3Factory(f?: S3ClientFactory): S3ClientFactory {
327
+ if (!f) {
328
+ throw new Error(
329
+ "Personal-vault browse requires an s3Factory (direct-S3 client).",
330
+ );
331
+ }
332
+ return f;
333
+ }
334
+
335
+ /** Guard: the company path needs the presign/list client factory. */
336
+ function requireCompanyClient(
337
+ f?: CompanyBrowseClientFactory,
338
+ ): CompanyBrowseClientFactory {
339
+ if (!f) {
340
+ throw new Error(
341
+ "Company browse requires a companyClient (presign/list client).",
342
+ );
343
+ }
344
+ return f;
302
345
  }
303
346
 
304
347
  /**
@@ -315,7 +358,7 @@ export interface RunBrowseResult {
315
358
  * Pure-ish: no console output, no process.exit — caller renders + exits.
316
359
  */
317
360
  export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult> {
318
- const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
361
+ const { pathPrefix, vaultClient, region, personalMode } = input;
319
362
 
320
363
  // Branch by mode. Company mode parses slug from path and looks up by
321
364
  // namespace; personal mode resolves the entity directly by the supplied
@@ -324,7 +367,8 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
324
367
  let bucket: string;
325
368
  let entityUid: string;
326
369
  let slug: string | undefined;
327
- let vend: BrowseVendResult;
370
+ let vend: BrowseVendResult | undefined;
371
+ let s3: FilesBrowseS3Client;
328
372
  if (personalMode) {
329
373
  if (!input.personalUid) {
330
374
  throw new Error(
@@ -340,7 +384,17 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
340
384
  }
341
385
  entityUid = entity.uid;
342
386
  bucket = entity.bucketName;
387
+ // Personal vault keeps the direct-S3 path: vend self creds, build an S3
388
+ // client. (HQ-59 scopes the migration to COMPANY mode.)
343
389
  vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
390
+ s3 = requirePersonalS3Factory(input.s3Factory)({
391
+ region,
392
+ credentials: {
393
+ accessKeyId: vend.credentials.accessKeyId,
394
+ secretAccessKey: vend.credentials.secretAccessKey,
395
+ sessionToken: vend.credentials.sessionToken,
396
+ },
397
+ });
344
398
  } else {
345
399
  slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
346
400
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
@@ -356,21 +410,11 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
356
410
  }
357
411
  entityUid = entity.uid;
358
412
  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 });
413
+ // COMPANY mode (HQ-59): list/get go through the presign/list API, which
414
+ // enforces the same per-file ACLs server-side. No STS vend, no direct S3.
415
+ s3 = requireCompanyClient(input.companyClient)({ companyUid: entityUid });
363
416
  }
364
417
 
365
- const s3 = s3Factory({
366
- region,
367
- credentials: {
368
- accessKeyId: vend.credentials.accessKeyId,
369
- secretAccessKey: vend.credentials.secretAccessKey,
370
- sessionToken: vend.credentials.sessionToken,
371
- },
372
- });
373
-
374
418
  // Company vault keys are company-relative (no `companies/<slug>/` prefix), so
375
419
  // translate the CLI's anchored prefix into the bucket-relative form before
376
420
  // listing. Personal-mode paths are already bucket-relative.
@@ -447,7 +491,10 @@ export interface RunCatInput {
447
491
  /** Canonical person-entity UID; required when `personalMode: true`. */
448
492
  personalUid?: string;
449
493
  vaultClient: FilesBrowseVaultClient;
450
- s3Factory: S3ClientFactory;
494
+ /** PERSONAL mode: builds a direct-S3 client from vended creds. */
495
+ s3Factory?: S3ClientFactory;
496
+ /** COMPANY mode (HQ-59): builds the presign/list-backed client. */
497
+ companyClient?: CompanyBrowseClientFactory;
451
498
  region: string;
452
499
  /** Destination stream for the stdout path. Injectable for tests. */
453
500
  stdout?: NodeJS.WritableStream;
@@ -456,7 +503,8 @@ export interface RunCatInput {
456
503
  export interface RunCatResult {
457
504
  bytesWritten: number;
458
505
  destination: { kind: "stdout" } | { kind: "file"; absPath: string };
459
- vend: BrowseVendResult;
506
+ /** Present ONLY for the PERSONAL (vendSelf + direct S3) path. */
507
+ vend?: BrowseVendResult;
460
508
  }
461
509
 
462
510
  /**
@@ -465,7 +513,7 @@ export interface RunCatResult {
465
513
  * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
466
514
  */
467
515
  export async function runCat(input: RunCatInput): Promise<RunCatResult> {
468
- const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
516
+ const { key, vaultClient, region, hqRoot, personalMode } = input;
469
517
 
470
518
  // Acceptance 5: refuse BEFORE vending — no point pulling credentials
471
519
  // for a request we're already going to abort.
@@ -478,7 +526,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
478
526
  // the personal-vs-company rationale.
479
527
  let bucket: string;
480
528
  let s3Key: string;
481
- let vend: BrowseVendResult;
529
+ let vend: BrowseVendResult | undefined;
530
+ let s3: FilesBrowseS3Client;
482
531
  if (personalMode) {
483
532
  if (!input.personalUid) {
484
533
  throw new Error(
@@ -495,6 +544,14 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
495
544
  bucket = entity.bucketName;
496
545
  s3Key = key; // personal-mode keys are already bucket-relative
497
546
  vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
547
+ s3 = requirePersonalS3Factory(input.s3Factory)({
548
+ region,
549
+ credentials: {
550
+ accessKeyId: vend.credentials.accessKeyId,
551
+ secretAccessKey: vend.credentials.secretAccessKey,
552
+ sessionToken: vend.credentials.sessionToken,
553
+ },
554
+ });
498
555
  } else {
499
556
  const slug = input.companySlug ?? parseCompanySlugFromPath(key);
500
557
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
@@ -511,18 +568,10 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
511
568
  bucket = entity.bucketName;
512
569
  // Translate the anchored CLI key into the company-relative bucket key.
513
570
  s3Key = toBucketRelative(key, slug);
514
- vend = await vaultClient.sts.vend({ companyUid: entity.uid });
571
+ // COMPANY mode (HQ-59): GetObject → presign GET. No STS vend, no direct S3.
572
+ s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
515
573
  }
516
574
 
517
- const s3 = s3Factory({
518
- region,
519
- credentials: {
520
- accessKeyId: vend.credentials.accessKeyId,
521
- secretAccessKey: vend.credentials.secretAccessKey,
522
- sessionToken: vend.credentials.sessionToken,
523
- },
524
- });
525
-
526
575
  const resp = (await s3.send(
527
576
  new GetObjectCommand({ Bucket: bucket, Key: s3Key }),
528
577
  )) as GetObjectCommandOutput;
@@ -686,7 +735,10 @@ export interface RunSearchInput {
686
735
  personalMode?: boolean;
687
736
  personalUid?: string;
688
737
  vaultClient: FilesBrowseVaultClient;
689
- s3Factory: S3ClientFactory;
738
+ /** PERSONAL mode: direct-S3 client factory. */
739
+ s3Factory?: S3ClientFactory;
740
+ /** COMPANY mode (HQ-59): presign/list client factory. */
741
+ companyClient?: CompanyBrowseClientFactory;
690
742
  region: string;
691
743
  }
692
744
 
@@ -707,6 +759,7 @@ export async function runSearch(input: RunSearchInput): Promise<BrowseRow[]> {
707
759
  personalUid: input.personalUid,
708
760
  vaultClient: input.vaultClient,
709
761
  s3Factory: input.s3Factory,
762
+ companyClient: input.companyClient,
710
763
  region: input.region,
711
764
  });
712
765
  const q = input.query.toLowerCase();
@@ -768,7 +821,11 @@ export interface RunGetInput {
768
821
  hqRoot: string;
769
822
  companySlug?: string;
770
823
  vaultClient: FilesBrowseVaultClient;
771
- s3Factory: S3ClientFactory;
824
+ /**
825
+ * COMPANY mode (HQ-59): presign/list client factory. `get` is company-only,
826
+ * so it always goes through the API — no direct S3, no STS vend.
827
+ */
828
+ companyClient?: CompanyBrowseClientFactory;
772
829
  region: string;
773
830
  }
774
831
 
@@ -791,7 +848,7 @@ export interface RunGetResult {
791
848
  * HQ root itself, which is too broad to do implicitly.
792
849
  */
793
850
  export async function runGet(input: RunGetInput): Promise<RunGetResult> {
794
- const { path: vaultPath, vaultClient, s3Factory, region, hqRoot } = input;
851
+ const { path: vaultPath, vaultClient, hqRoot } = input;
795
852
  const slug = input.companySlug ?? parseCompanySlugFromPath(vaultPath);
796
853
 
797
854
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
@@ -804,15 +861,9 @@ export async function runGet(input: RunGetInput): Promise<RunGetResult> {
804
861
  throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
805
862
  }
806
863
  const bucket = entity.bucketName;
807
- const vend = await vaultClient.sts.vend({ companyUid: entity.uid });
808
- const s3 = s3Factory({
809
- region,
810
- credentials: {
811
- accessKeyId: vend.credentials.accessKeyId,
812
- secretAccessKey: vend.credentials.secretAccessKey,
813
- sessionToken: vend.credentials.sessionToken,
814
- },
815
- });
864
+ // COMPANY mode (HQ-59): list + get through the presign/list API. No STS vend,
865
+ // no direct S3 — the server enforces the same per-file read ACLs.
866
+ const s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
816
867
 
817
868
  // Company-relative prefix to list/fetch (bucket keys carry no anchor).
818
869
  const bucketPrefix = toBucketRelative(vaultPath, slug);
@@ -882,6 +933,134 @@ export async function runGet(input: RunGetInput): Promise<RunGetResult> {
882
933
  const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
883
934
  new S3Client({ region, credentials });
884
935
 
936
+ // ── Company-mode presign/list client (HQ-59) ────────────────────────────────
937
+ //
938
+ // The COMPANY browse/cat/get/search path no longer talks to S3 directly. Per
939
+ // the HQ-59 directive ("no client talks to S3 directly"), it goes through the
940
+ // server-side vault API, which enforces the SAME per-file ACLs the STS vend
941
+ // policy used to — so the per-file STS scoping can be dropped later:
942
+ // - ListObjectsV2 → GET /v1/files/list (flat, ACL-filtered, paginated)
943
+ // - GetObject → POST /v1/files/presign (op:'get') → fetch the URL
944
+ //
945
+ // It implements the existing `FilesBrowseS3Client` interface so the
946
+ // orchestrators' S3-shaped calls are unchanged; the `Bucket` field on each
947
+ // command is ignored (the server resolves the bucket from the companyUid).
948
+ // The PERSONAL (vendSelf) path keeps using direct S3 via `defaultS3Factory`.
949
+
950
+ /** Shape of one object in a GET /v1/files/list response. */
951
+ interface FilesListObject {
952
+ key: string;
953
+ size: number;
954
+ lastModified: string | null;
955
+ etag: string | null;
956
+ permission: string;
957
+ }
958
+
959
+ /**
960
+ * Build a COMPANY-mode browse client backed by the list + presign API. The
961
+ * access token + companyUid are captured here; the orchestrator just calls
962
+ * `send(...)` as if it held an S3 client.
963
+ */
964
+ export function createCompanyPresignClient(input: {
965
+ token: string;
966
+ companyUid: string;
967
+ }): FilesBrowseS3Client {
968
+ const { token, companyUid } = input;
969
+
970
+ async function listObjects(
971
+ cmd: ListObjectsV2Command,
972
+ ): Promise<ListObjectsV2CommandOutput> {
973
+ const prefix = cmd.input.Prefix ?? "";
974
+ const query: Record<string, string> = { company: companyUid };
975
+ if (prefix.length > 0) query.prefix = prefix;
976
+ if (cmd.input.ContinuationToken) query.cursor = cmd.input.ContinuationToken;
977
+
978
+ const res = await vaultApiFetch({ token, path: "/v1/files/list", query });
979
+ if (!res.ok) {
980
+ const body = (await res.json().catch(() => ({}))) as Record<string, string>;
981
+ throw new Error(
982
+ body.message ?? body.error ?? `files list failed (${res.status})`,
983
+ );
984
+ }
985
+ const body = (await res.json()) as {
986
+ objects?: FilesListObject[];
987
+ cursor?: string | null;
988
+ truncated?: boolean;
989
+ };
990
+ return {
991
+ // Map the API's company-relative objects onto the ListObjectsV2 shape the
992
+ // orchestrators read (Key / Size / LastModified / ETag). Re-quote the
993
+ // etag to match S3's quoted form, in case any caller compares it.
994
+ Contents: (body.objects ?? []).map((o) => ({
995
+ Key: o.key,
996
+ Size: o.size,
997
+ LastModified: o.lastModified ? new Date(o.lastModified) : undefined,
998
+ ETag: o.etag != null ? `"${o.etag}"` : undefined,
999
+ })),
1000
+ NextContinuationToken: body.cursor ?? undefined,
1001
+ IsTruncated: Boolean(body.truncated),
1002
+ // $metadata is required by the SDK output type; the orchestrators never
1003
+ // read it, so a minimal stub is sufficient.
1004
+ $metadata: {},
1005
+ } as ListObjectsV2CommandOutput;
1006
+ }
1007
+
1008
+ async function getObject(
1009
+ cmd: GetObjectCommand,
1010
+ ): Promise<GetObjectCommandOutput> {
1011
+ const key = cmd.input.Key as string;
1012
+ const res = await vaultApiFetch({
1013
+ token,
1014
+ path: "/v1/files/presign",
1015
+ method: "POST",
1016
+ body: { company: companyUid, key, op: "get" },
1017
+ });
1018
+ if (!res.ok) {
1019
+ const body = (await res.json().catch(() => ({}))) as Record<string, string>;
1020
+ throw new Error(
1021
+ body.message ?? body.error ?? `presign failed (${res.status})`,
1022
+ );
1023
+ }
1024
+ const body = (await res.json()) as {
1025
+ results?: Array<{ key: string; url?: string; error?: string; code?: string }>;
1026
+ };
1027
+ const first = body.results?.[0];
1028
+ if (!first || !first.url) {
1029
+ // Per-key denial/validation surfaces here (e.g. FILES_PRESIGN_FORBIDDEN).
1030
+ throw new Error(first?.error ?? `No presigned URL returned for '${key}'`);
1031
+ }
1032
+
1033
+ const dl = await fetch(first.url);
1034
+ if (!dl.ok) {
1035
+ throw new Error(`Failed to download '${key}' (HTTP ${dl.status})`);
1036
+ }
1037
+ // fetch() yields a web ReadableStream; the orchestrators consume Body as a
1038
+ // Node Readable (body.on('data') + stream pipeline), so adapt it. An empty
1039
+ // body (no stream) becomes an empty Readable.
1040
+ const nodeBody = dl.body
1041
+ ? Readable.fromWeb(dl.body as Parameters<typeof Readable.fromWeb>[0])
1042
+ : Readable.from([]);
1043
+ return { Body: nodeBody, $metadata: {} } as unknown as GetObjectCommandOutput;
1044
+ }
1045
+
1046
+ function send(cmd: ListObjectsV2Command): Promise<ListObjectsV2CommandOutput>;
1047
+ function send(cmd: GetObjectCommand): Promise<GetObjectCommandOutput>;
1048
+ function send(
1049
+ cmd: ListObjectsV2Command | GetObjectCommand,
1050
+ ): Promise<ListObjectsV2CommandOutput | GetObjectCommandOutput> {
1051
+ if (cmd instanceof ListObjectsV2Command) return listObjects(cmd);
1052
+ if (cmd instanceof GetObjectCommand) return getObject(cmd);
1053
+ throw new Error("createCompanyPresignClient: unsupported S3 command");
1054
+ }
1055
+
1056
+ return { send };
1057
+ }
1058
+
1059
+ /** Production company-mode client factory — closes over the caller's token. */
1060
+ function makeCompanyPresignFactory(token: string): CompanyBrowseClientFactory {
1061
+ return ({ companyUid }) => createCompanyPresignClient({ token, companyUid });
1062
+ }
1063
+
885
1064
  interface FilesBrowseCliOptions {
886
1065
  company?: string;
887
1066
  hqRoot: string;
@@ -1013,7 +1192,7 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
1013
1192
  pathPrefix: pathArg,
1014
1193
  companySlug: slug,
1015
1194
  vaultClient: client,
1016
- s3Factory: defaultS3Factory,
1195
+ companyClient: makeCompanyPresignFactory(accessToken),
1017
1196
  region: DEFAULT_COGNITO.region,
1018
1197
  });
1019
1198
 
@@ -1127,7 +1306,7 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
1127
1306
  hqRoot: options.hqRoot,
1128
1307
  companySlug: slug,
1129
1308
  vaultClient: client,
1130
- s3Factory: defaultS3Factory,
1309
+ companyClient: makeCompanyPresignFactory(accessToken),
1131
1310
  region: DEFAULT_COGNITO.region,
1132
1311
  });
1133
1312
 
@@ -1240,7 +1419,7 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
1240
1419
  query,
1241
1420
  companySlug: company,
1242
1421
  vaultClient: client,
1243
- s3Factory: defaultS3Factory,
1422
+ companyClient: makeCompanyPresignFactory(accessToken),
1244
1423
  region: DEFAULT_COGNITO.region,
1245
1424
  });
1246
1425
  console.log(formatBrowseTable(rows));
@@ -1301,7 +1480,7 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
1301
1480
  hqRoot: options.hqRoot,
1302
1481
  companySlug: slug,
1303
1482
  vaultClient: client,
1304
- s3Factory: defaultS3Factory,
1483
+ companyClient: makeCompanyPresignFactory(accessToken),
1305
1484
  region: DEFAULT_COGNITO.region,
1306
1485
  });
1307
1486