@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.
- package/dist/commands/files-browse.d.ts +46 -6
- package/dist/commands/files-browse.js +134 -40
- package/dist/commands/signals.js +8 -3
- package/dist/commands/sources.js +8 -3
- package/package.json +2 -2
- package/src/commands/files-browse.test.ts +205 -41
- package/src/commands/files-browse.ts +227 -48
- package/src/commands/signals.ts +6 -0
- package/src/commands/sources.ts +6 -0
- package/test/commands/signals.test.ts +15 -8
- package/test/commands/sources.test.ts +15 -10
- package/test/helpers/vault-service-mock.ts +72 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1483
|
+
companyClient: makeCompanyPresignFactory(accessToken),
|
|
1305
1484
|
region: DEFAULT_COGNITO.region,
|
|
1306
1485
|
});
|
|
1307
1486
|
|
package/src/commands/signals.ts
CHANGED
|
@@ -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") {
|
package/src/commands/sources.ts
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
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) => ({
|