@indigoai-us/hq-cli 5.47.6 → 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.
- package/dist/commands/files-browse.d.ts +46 -6
- package/dist/commands/files-browse.js +134 -40
- package/package.json +2 -2
- package/src/commands/files-browse.test.ts +205 -41
- package/src/commands/files-browse.ts +227 -48
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]="
|
|
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,
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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.
|
|
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 {
|
|
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("
|
|
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 {
|
|
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
|
-
|
|
385
|
+
companyClient: companyFactory,
|
|
371
386
|
region: "us-east-1",
|
|
372
387
|
});
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
expect(spies.stsVend.
|
|
388
|
+
// HQ-59: company browse no longer vends STS creds — it 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
|
-
//
|
|
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 {
|
|
388
|
-
//
|
|
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
|
-
|
|
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("
|
|
618
|
+
it("company mode fetches the company-relative key via presign (no STS vend)", async () => {
|
|
604
619
|
const { client, spies } = makeStubVaultClient({});
|
|
605
|
-
const {
|
|
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
|
-
|
|
630
|
+
companyClient: companyFactory,
|
|
616
631
|
region: "us-east-1",
|
|
617
632
|
hqRoot: tmpRoot,
|
|
618
633
|
stdout: sink,
|
|
619
634
|
});
|
|
620
|
-
|
|
621
|
-
expect(spies.stsVend.
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
678
|
+
companyClient: companyFactory,
|
|
664
679
|
region: "us-east-1",
|
|
665
680
|
hqRoot: tmpRoot,
|
|
666
681
|
}),
|
|
667
682
|
).rejects.toThrow(/Refusing to write/);
|
|
668
|
-
// Critically:
|
|
669
|
-
// was made
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
869
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
}): {
|
|
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
|
-
|
|
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
|
|
943
|
-
return {
|
|
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 {
|
|
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
|
-
|
|
979
|
+
companyClient: companyFactory,
|
|
963
980
|
region: "us-east-1",
|
|
964
981
|
});
|
|
965
982
|
|
|
966
|
-
//
|
|
967
|
-
expect(spies.stsVend).
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|