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