@indigoai-us/hq-cli 5.47.6 → 5.47.8

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.
@@ -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
 
@@ -31,6 +31,7 @@ import {
31
31
  listAvailableEntities,
32
32
  listSignals,
33
33
  getSignal,
34
+ VaultClient,
34
35
  type SignalType,
35
36
  type SignalSummary,
36
37
  type SignalDocument,
@@ -164,6 +165,9 @@ async function runList(options: ListOptions): Promise<void> {
164
165
  limit,
165
166
  continuationToken: options.pageToken,
166
167
  includeFrontmatter: options.includeFrontmatter,
168
+ // HQ-59: route company (cmp_) reads through the ACL-filtered presign
169
+ // transport (no direct S3). Personal vaults / no-client stay on S3.
170
+ vault: new VaultClient(vaultConfig),
167
171
  });
168
172
 
169
173
  if (format === "json") {
@@ -212,6 +216,8 @@ async function runGet(options: GetOptions): Promise<void> {
212
216
  entity,
213
217
  signalType,
214
218
  signalId: options.id,
219
+ // HQ-59: company (cmp_) reads over presign; personal/no-client stay on S3.
220
+ vault: new VaultClient(vaultConfig),
215
221
  });
216
222
 
217
223
  if (format === "json") {
@@ -29,6 +29,7 @@ import {
29
29
  listAvailableEntities,
30
30
  listSources,
31
31
  getSource,
32
+ VaultClient,
32
33
  type SourceChannel,
33
34
  type SourceSummary,
34
35
  type SourceDocument,
@@ -172,6 +173,9 @@ async function runList(options: ListOptions): Promise<void> {
172
173
  limit,
173
174
  continuationToken: options.pageToken,
174
175
  includeFrontmatter: options.includeFrontmatter,
176
+ // HQ-59: route company (cmp_) reads through the ACL-filtered presign
177
+ // transport (no direct S3). Personal vaults / no-client stay on S3.
178
+ vault: new VaultClient(vaultConfig),
175
179
  });
176
180
 
177
181
  if (format === "json") {
@@ -222,6 +226,8 @@ async function runGet(options: GetOptions): Promise<void> {
222
226
  channel,
223
227
  sourceId: options.id,
224
228
  includeRaw: options.includeRaw,
229
+ // HQ-59: company (cmp_) reads over presign; personal/no-client stay on S3.
230
+ vault: new VaultClient(vaultConfig),
225
231
  });
226
232
 
227
233
  if (format === "json") {
@@ -24,7 +24,6 @@ import {
24
24
  _setSignalsS3Factory,
25
25
  _resetSignalsS3Factory,
26
26
  } from "@indigoai-us/hq-cloud";
27
- import { mockS3WithEntries } from "../helpers/s3-list-mock.js";
28
27
  import { mockVaultService } from "../helpers/vault-service-mock.js";
29
28
  import { runCli } from "../helpers/cli-runner.js";
30
29
 
@@ -62,8 +61,21 @@ beforeEach(() => {
62
61
 
63
62
  tmpHqRoot = mkdtempSync(join(tmpdir(), "hq-signals-test-"));
64
63
 
65
- const s3 = mockS3WithEntries({
66
- entries: [
64
+ // Transport-switch proof: every read in this suite targets a COMPANY (cmp_)
65
+ // entity, which routes through presign as of HQ-59. The direct-S3 factory is
66
+ // wired to THROW so any read accidentally falling back to S3 fails loudly
67
+ // (mirrors hq-cloud's #88 "S3 stub throws if touched" tests).
68
+ _setSignalsS3Factory(() => {
69
+ throw new Error(
70
+ "S3 transport must not be used for a company (cmp_) read — HQ-59 routes these through presign",
71
+ );
72
+ });
73
+
74
+ // `files` serves the fixture over the presigned-URL transport (the path
75
+ // company reads take).
76
+ restoreFetch = mockVaultService({
77
+ entities: [{ uid: "cmp_indigo_001", slug: "indigo", bucketName: "hq-indigo-bucket" }],
78
+ files: [
67
79
  {
68
80
  key: "signals/action_item/xyz.md",
69
81
  content: ACTION_ITEM_MD,
@@ -71,11 +83,6 @@ beforeEach(() => {
71
83
  },
72
84
  ],
73
85
  });
74
- _setSignalsS3Factory(() => s3);
75
-
76
- restoreFetch = mockVaultService({
77
- entities: [{ uid: "cmp_indigo_001", slug: "indigo", bucketName: "hq-indigo-bucket" }],
78
- });
79
86
  });
80
87
 
81
88
  afterEach(() => {
@@ -26,7 +26,6 @@ import {
26
26
  _setSourcesS3Factory,
27
27
  _resetSourcesS3Factory,
28
28
  } from "@indigoai-us/hq-cloud";
29
- import { mockS3WithEntries } from "../helpers/s3-list-mock.js";
30
29
  import { mockVaultService } from "../helpers/vault-service-mock.js";
31
30
  import { runCli } from "../helpers/cli-runner.js";
32
31
 
@@ -72,9 +71,21 @@ beforeEach(() => {
72
71
  // test writes config.json into it.
73
72
  tmpHqRoot = mkdtempSync(join(tmpdir(), "hq-sources-test-"));
74
73
 
75
- // Default S3 stub returns the one meeting source.
76
- const s3 = mockS3WithEntries({
77
- entries: [
74
+ // Transport-switch proof: every read in this suite targets a COMPANY (cmp_)
75
+ // entity, which routes through presign as of HQ-59. The direct-S3 factory is
76
+ // wired to THROW so that any read accidentally falling back to S3 fails the
77
+ // test loudly (mirrors hq-cloud's #88 "S3 stub throws if touched" tests).
78
+ _setSourcesS3Factory(() => {
79
+ throw new Error(
80
+ "S3 transport must not be used for a company (cmp_) read — HQ-59 routes these through presign",
81
+ );
82
+ });
83
+
84
+ // Default vault-service mock with one entity 'indigo'. `files` serves the
85
+ // fixture over the presigned-URL transport (the path company reads take).
86
+ restoreFetch = mockVaultService({
87
+ entities: [{ uid: "cmp_indigo_001", slug: "indigo", bucketName: "hq-indigo-bucket" }],
88
+ files: [
78
89
  {
79
90
  key: "sources/meetings/abc.md",
80
91
  content: MEETING_MD,
@@ -82,12 +93,6 @@ beforeEach(() => {
82
93
  },
83
94
  ],
84
95
  });
85
- _setSourcesS3Factory(() => s3);
86
-
87
- // Default vault-service mock with one entity 'indigo'.
88
- restoreFetch = mockVaultService({
89
- entities: [{ uid: "cmp_indigo_001", slug: "indigo", bucketName: "hq-indigo-bucket" }],
90
- });
91
96
  });
92
97
 
93
98
  afterEach(() => {
@@ -33,12 +33,33 @@ export interface MockStsCredentials {
33
33
  sessionToken: string;
34
34
  }
35
35
 
36
+ /** A file served over the presigned-URL transport (HQ-59 company reads). */
37
+ export interface MockVaultFile {
38
+ /** Object key (e.g. "sources/meetings/abc.md") */
39
+ key: string;
40
+ /** Full file content as UTF-8 string */
41
+ content: string;
42
+ /** Optional override; defaults to 2026-01-01T00:00:00Z */
43
+ lastModified?: Date;
44
+ }
45
+
36
46
  export interface MockVaultOptions {
37
47
  entities: MockEntity[];
38
48
  /** STS credentials returned for every POST /entities/{uid}/sts; defaults to test creds */
39
49
  stsCredentials?: MockStsCredentials;
50
+ /**
51
+ * Files served over the presigned-URL transport (`GET /v1/files/list`,
52
+ * `POST /v1/files/presign`, then a GET against the minted URL). Supplied so
53
+ * company (cmp_) reads — which route through presign as of HQ-59 — resolve
54
+ * without touching real S3. Absent → those endpoints return empty/404.
55
+ */
56
+ files?: MockVaultFile[];
40
57
  }
41
58
 
59
+ /** Host for mock presigned URLs minted by POST /v1/files/presign. */
60
+ const PRESIGN_URL_HOST = "https://presigned.test";
61
+ const DEFAULT_FILE_DATE = new Date("2026-01-01T00:00:00Z");
62
+
42
63
  const DEFAULT_STS: MockStsCredentials = {
43
64
  accessKeyId: "ASIAMOCK000000000001",
44
65
  secretAccessKey: "mockSecretAccessKey",
@@ -51,7 +72,7 @@ const DEFAULT_STS: MockStsCredentials = {
51
72
  * @returns A restore function — call it in afterEach to un-install the stub.
52
73
  */
53
74
  export function mockVaultService(opts: MockVaultOptions): () => void {
54
- const { entities, stsCredentials = DEFAULT_STS } = opts;
75
+ const { entities, stsCredentials = DEFAULT_STS, files = [] } = opts;
55
76
  const originalFetch = globalThis.fetch as typeof fetch | undefined;
56
77
 
57
78
  globalThis.fetch = async (
@@ -67,6 +88,56 @@ export function mockVaultService(opts: MockVaultOptions): () => void {
67
88
  const method =
68
89
  (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
69
90
 
91
+ // ── Presigned-URL transport (HQ-59 company reads) ──────────────────────
92
+ // A GET against a URL minted by POST /v1/files/presign below. Serve the
93
+ // file body (or 404) so PresignObjectIO.getObject resolves like real S3.
94
+ if (url.startsWith(`${PRESIGN_URL_HOST}/`)) {
95
+ const key = new URL(url).searchParams.get("key") ?? "";
96
+ const file = files.find((f) => f.key === key);
97
+ if (!file) return new Response("", { status: 404 });
98
+ return new Response(file.content, {
99
+ status: 200,
100
+ headers: {
101
+ "Content-Type": "text/markdown",
102
+ "Content-Length": String(Buffer.byteLength(file.content, "utf-8")),
103
+ "Last-Modified": (file.lastModified ?? DEFAULT_FILE_DATE).toUTCString(),
104
+ ETag: '"mock-etag"',
105
+ },
106
+ });
107
+ }
108
+
109
+ // GET /v1/files/list?company=<uid>&prefix=<p>&cursor=<c> (VaultClient.listFiles)
110
+ if (method === "GET" && /\/v1\/files\/list(\?|$)/.test(url)) {
111
+ const prefix = new URL(url).searchParams.get("prefix") ?? "";
112
+ const objects = files
113
+ .filter((f) => f.key.startsWith(prefix))
114
+ .map((f) => ({
115
+ key: f.key,
116
+ size: Buffer.byteLength(f.content, "utf-8"),
117
+ lastModified: (f.lastModified ?? DEFAULT_FILE_DATE).toISOString(),
118
+ etag: "mock-etag",
119
+ permission: "read",
120
+ }));
121
+ return json({ objects, cursor: null, truncated: false });
122
+ }
123
+
124
+ // POST /v1/files/presign (VaultClient.presign) — mint a per-key URL that
125
+ // the GET handler above resolves. Unknown keys still get a URL → that GET
126
+ // 404s, which the reader normalizes to NoSuchKey (the not-found path).
127
+ if (method === "POST" && /\/v1\/files\/presign$/.test(url)) {
128
+ const body = init?.body ? JSON.parse(init.body.toString()) : {};
129
+ const keys = (body.keys ?? []) as Array<{ key: string }>;
130
+ const results = keys.map((k) => ({
131
+ key: k.key,
132
+ url: `${PRESIGN_URL_HOST}/obj?key=${encodeURIComponent(k.key)}`,
133
+ error: null,
134
+ }));
135
+ return json({
136
+ results,
137
+ expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
138
+ });
139
+ }
140
+
70
141
  // GET /membership/me
71
142
  if (method === "GET" && /\/membership\/me(\?.*)?$/.test(url)) {
72
143
  const memberships: Partial<Membership>[] = entities.map((e) => ({