@indigoai-us/hq-cli 5.26.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.
@@ -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 via the new `purpose: 'browse'` path
21
- * (`VaultClient.vend`) shipped in hq-cloud US-009. The server treats that
22
- * purpose as the role-bypass-allowed surface sync vends NEVER widen, so
23
- * keeping browse on its own vend call is the acceptance-criteria-1
24
- * separation we need.
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
- * Cross-package note: depends on `VendInput`/`VendResult` + the
27
- * `VaultClient.vend` method from hq-cloud US-009 (commit 2f790c5).
28
- * hq-cli pins `@indigoai-us/hq-cloud` to `file:../hq-cloud` via
29
- * `pnpm.overrides` until that release ships to npm.
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 VendResult, type ExplicitGrant } from "@indigoai-us/hq-cloud";
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
- vend(input: {
40
- paths: string[];
41
- operations: "read-only" | "read-write" | "staged-write";
42
- purpose: "sync" | "browse";
43
- duration?: number;
44
- }): Promise<VendResult>;
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
- * Classify a single S3 key against the caller's explicit-grant list. Any
100
- * grant whose `path` is a prefix of the key contributes `shared-with-you`;
101
- * otherwise the key is only visible via role-bypass on the vend call.
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 paths and S3 keys live in the same canonical form ("companies/<slug>/…");
104
- * `coalescePrefixes` would shrink the list further but isn't required for
105
- * correctness`startsWith` already short-circuits on the first match.
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(key: string, grants: ExplicitGrant[]): AclSource;
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: VendResult;
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 with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
162
- * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
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: VendResult;
246
+ vend: BrowseVendResult;
201
247
  }
202
248
  /**
203
249
  * `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
@@ -205,6 +251,55 @@ export interface RunCatResult {
205
251
  * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
206
252
  */
207
253
  export declare function runCat(input: RunCatInput): Promise<RunCatResult>;
254
+ /**
255
+ * Subset of `VaultClient` the `shared-with-me` orchestrator uses. No vend / S3
256
+ * — this is a pure read of the caller's explicit-grant graph, so it never
257
+ * touches the credential/browse vend surface.
258
+ */
259
+ export interface FilesSharedWithMeVaultClient {
260
+ listMyMemberships(): Promise<Array<{
261
+ companyUid: string;
262
+ }>>;
263
+ listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
264
+ entity: {
265
+ get(uid: string): Promise<{
266
+ uid: string;
267
+ slug: string;
268
+ name?: string;
269
+ }>;
270
+ };
271
+ }
272
+ export interface SharedWithMeRow {
273
+ companySlug: string;
274
+ /** Company-relative grant path (e.g. `knowledge/`, `reports/q3.pdf`). */
275
+ path: string;
276
+ permission: ExplicitGrant["permission"];
277
+ source: ExplicitGrant["source"];
278
+ }
279
+ export interface RunSharedWithMeInput {
280
+ vaultClient: FilesSharedWithMeVaultClient;
281
+ /**
282
+ * Scope to a single company by UID. When omitted, rolls up across every
283
+ * company the caller has a membership in (the cross-company "what's shared
284
+ * with me everywhere" view).
285
+ */
286
+ companyUid?: string;
287
+ /** Display slug for the single-company case (avoids an extra entity.get). */
288
+ companySlug?: string;
289
+ }
290
+ /**
291
+ * `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
292
+ * file-ACL grants — the canonical "what's been shared with me" surface.
293
+ * Role-bypass access (owner/admin) is intentionally excluded server-side by
294
+ * `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
295
+ *
296
+ * Pure data — no console output, no S3, no vend. The caller renders + exits.
297
+ */
298
+ export declare function runSharedWithMe(input: RunSharedWithMeInput): Promise<SharedWithMeRow[]>;
299
+ /**
300
+ * Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
301
+ */
302
+ export declare function formatSharedWithMeTable(rows: SharedWithMeRow[]): string;
208
303
  /**
209
304
  * Wire `hq files browse` + `hq files cat` onto an existing `files`
210
305
  * Commander group. `registerFilesCommand` in files.ts builds the group
@@ -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 via the new `purpose: 'browse'` path
21
- * (`VaultClient.vend`) shipped in hq-cloud US-009. The server treats that
22
- * purpose as the role-bypass-allowed surface sync vends NEVER widen, so
23
- * keeping browse on its own vend call is the acceptance-criteria-1
24
- * separation we need.
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
- * Cross-package note: depends on `VendInput`/`VendResult` + the
27
- * `VaultClient.vend` method from hq-cloud US-009 (commit 2f790c5).
28
- * hq-cli pins `@indigoai-us/hq-cloud` to `file:../hq-cloud` via
29
- * `pnpm.overrides` until that release ships to npm.
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]="55a4ece7-6b33-5809-97c0-475c423bc012")}catch(e){}}();
37
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="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
- * Classify a single S3 key against the caller's explicit-grant list. Any
58
- * grant whose `path` is a prefix of the key contributes `shared-with-you`;
59
- * otherwise the key is only visible via role-bypass on the vend call.
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 paths and S3 keys live in the same canonical form ("companies/<slug>/…");
62
- * `coalescePrefixes` would shrink the list further but isn't required for
63
- * correctness`startsWith` already short-circuits on the first match.
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(key, grants) {
66
- for (const g of grants) {
67
- if (g.path && key.startsWith(g.path))
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 with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
120
- * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
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). The vend call is
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
- const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
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. Skipped in personal mode — the
178
- // grants graph is a company concept; a person bucket marks every row
179
- // as `"personal-vault"` directly.
180
- const grants = personalMode
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: pathPrefix,
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: obj.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 ? "personal-vault" : classifyAclSource(obj.Key, grants),
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: 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
  }
@@ -283,6 +327,79 @@ export async function runCat(input) {
283
327
  await pipeline(body, input.stdout ?? process.stdout);
284
328
  return { bytesWritten, destination: { kind: "stdout" }, vend };
285
329
  }
330
+ /**
331
+ * `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
332
+ * file-ACL grants — the canonical "what's been shared with me" surface.
333
+ * Role-bypass access (owner/admin) is intentionally excluded server-side by
334
+ * `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
335
+ *
336
+ * Pure data — no console output, no S3, no vend. The caller renders + exits.
337
+ */
338
+ export async function runSharedWithMe(input) {
339
+ const { vaultClient } = input;
340
+ // Resolve the (companyUid, slug) pairs to query. Single-company when a UID
341
+ // was supplied; otherwise fan out across every membership.
342
+ let targets;
343
+ if (input.companyUid) {
344
+ targets = [{ uid: input.companyUid, slug: input.companySlug ?? input.companyUid }];
345
+ }
346
+ else {
347
+ const memberships = await vaultClient.listMyMemberships();
348
+ targets = await Promise.all(memberships.map(async (m) => {
349
+ try {
350
+ const ent = await vaultClient.entity.get(m.companyUid);
351
+ return { uid: m.companyUid, slug: ent.slug || m.companyUid };
352
+ }
353
+ catch {
354
+ // Entity not visible — fall back to the UID as the display label
355
+ // rather than dropping the company's grants entirely.
356
+ return { uid: m.companyUid, slug: m.companyUid };
357
+ }
358
+ }));
359
+ }
360
+ const rows = [];
361
+ for (const t of targets) {
362
+ let grants;
363
+ try {
364
+ grants = await vaultClient.listMyExplicitGrants(t.uid);
365
+ }
366
+ catch {
367
+ // A single company's grant fetch failing shouldn't sink the whole
368
+ // roll-up — skip it and continue (best-effort discovery view).
369
+ continue;
370
+ }
371
+ for (const g of grants) {
372
+ rows.push({
373
+ companySlug: t.slug,
374
+ path: g.path,
375
+ permission: g.permission,
376
+ source: g.source,
377
+ });
378
+ }
379
+ }
380
+ // Stable sort: company, then path — deterministic output for humans + tests.
381
+ rows.sort((a, b) => a.companySlug === b.companySlug
382
+ ? a.path.localeCompare(b.path)
383
+ : a.companySlug.localeCompare(b.companySlug));
384
+ return rows;
385
+ }
386
+ /**
387
+ * Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
388
+ */
389
+ export function formatSharedWithMeTable(rows) {
390
+ if (rows.length === 0) {
391
+ return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
392
+ }
393
+ const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
394
+ const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
395
+ const widths = cols.map((c, i) => Math.max(c.length, ...data.map((row) => row[i].length)));
396
+ const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
397
+ return [
398
+ chalk.bold(renderRow(cols)),
399
+ chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
400
+ ...data.map(renderRow),
401
+ ].join("\n");
402
+ }
286
403
  // ── CLI registration ────────────────────────────────────────────────────────
287
404
  const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
288
405
  /**
@@ -447,6 +564,33 @@ export function registerFilesBrowseCommands(filesCmd) {
447
564
  process.exit(1);
448
565
  }
449
566
  });
567
+ filesCmd
568
+ .command("shared-with-me")
569
+ .description("List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).")
570
+ .option("--company <slug>", "Scope to a single company (defaults to a cross-company roll-up).")
571
+ .action(async (options) => {
572
+ try {
573
+ const accessToken = await ensureCognitoToken();
574
+ const vaultConfig = buildVaultConfig(accessToken);
575
+ const client = new VaultClient(vaultConfig);
576
+ let companyUid;
577
+ if (options.company) {
578
+ // Confirm membership + resolve UID, same early-failure pattern as
579
+ // browse/cat. Roll-up mode skips this and fans out internally.
580
+ companyUid = await getCompanyUid(accessToken, options.company);
581
+ }
582
+ const rows = await runSharedWithMe({
583
+ vaultClient: client,
584
+ companyUid,
585
+ companySlug: options.company,
586
+ });
587
+ console.log(formatSharedWithMeTable(rows));
588
+ }
589
+ catch (err) {
590
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
591
+ process.exit(1);
592
+ }
593
+ });
450
594
  }
451
595
  //# sourceMappingURL=files-browse.js.map
452
- //# debugId=55a4ece7-6b33-5809-97c0-475c423bc012
596
+ //# debugId=e2f338ce-5871-59ad-ae56-99bd29e1711c
@@ -37,11 +37,11 @@
37
37
  * `file:../hq-cloud` via `pnpm.overrides`.
38
38
  */
39
39
 
40
- !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]="a6c1e2b9-5d9a-57b2-a80b-adfdbce88206")}catch(e){}}();
40
+ !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]="8cdf980e-e3cf-585c-b2e5-1615765675a1")}catch(e){}}();
41
41
  import chalk from "chalk";
42
42
  import * as readline from "node:readline";
43
43
  import * as fs from "node:fs";
44
- import { VaultClient, coalescePrefixes, readJournal, writeJournal, tombstoneEntry, } from "@indigoai-us/hq-cloud";
44
+ import { VaultClient, coalescePrefixes, grantPathToPrefix, readJournal, writeJournal, tombstoneEntry, } from "@indigoai-us/hq-cloud";
45
45
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
46
46
  import { readActiveCompanySlug } from "./sync-mode.js";
47
47
  import { buildNarrowPlan, formatBytes, formatNarrowPlanSummary, } from "../lib/local-tree-diff.js";
@@ -115,7 +115,18 @@ export async function computeNarrowPlan(input) {
115
115
  const { hqRoot, companySlug, companyUid, vaultClient } = input;
116
116
  const io = input.journalIO ?? realJournalIO;
117
117
  const grants = await vaultClient.listMyExplicitGrants(companyUid);
118
- const prospectivePrefixSet = coalescePrefixes(grants.map((g) => g.path));
118
+ // Normalize each grant into a company-relative, startsWith-friendly prefix
119
+ // (grantPathToPrefix, hq-cloud ≥5.42.0): real grants are anchored
120
+ // (`companies/<slug>/x/*`, `<slug>/x/*`) and glob-style (`x/*`, bare `*`),
121
+ // none of which startsWith-match the company-relative local-tree keys
122
+ // buildNarrowPlan emits. A wildcard grant normalizes to "" (everything);
123
+ // coalescePrefixes drops empties, so guard it explicitly to `[""]` (which
124
+ // isCoveredByAny treats as covering everything → nothing orphaned) rather
125
+ // than letting it collapse to "nothing" and propose deleting the tree.
126
+ const normalizedPrefixes = grants.map((g) => grantPathToPrefix(g.path, companySlug));
127
+ const prospectivePrefixSet = normalizedPrefixes.some((p) => p === "")
128
+ ? [""]
129
+ : coalescePrefixes(normalizedPrefixes);
119
130
  const journal = io.read(companySlug);
120
131
  const plan = buildNarrowPlan({
121
132
  hqRoot,
@@ -324,4 +335,4 @@ export function registerSyncNarrowCommand(syncCmd) {
324
335
  });
325
336
  }
326
337
  //# sourceMappingURL=sync-narrow.js.map
327
- //# debugId=a6c1e2b9-5d9a-57b2-a80b-adfdbce88206
338
+ //# debugId=8cdf980e-e3cf-585c-b2e5-1615765675a1
@@ -26,7 +26,11 @@
26
26
  import { type SyncJournal } from "@indigoai-us/hq-cloud";
27
27
  export type DirtyReason = "modified-after-sync" | "hash-mismatch" | "not-in-journal" | "stat-error";
28
28
  export interface NarrowFile {
29
- /** Path relative to `hqRoot` (matches journal key + S3 key naming). */
29
+ /**
30
+ * COMPANY-RELATIVE path (e.g. `meetings/a.md`) — the canonical namespace
31
+ * shared by the per-company journal keys, the vault S3 keys, and the
32
+ * server's explicit-grant paths. NOT hq-root-relative.
33
+ */
30
34
  relPath: string;
31
35
  /** Absolute path on disk (convenience for the CLI delete loop). */
32
36
  absPath: string;
@@ -57,7 +61,8 @@ export interface BuildNarrowPlanInput {
57
61
  /**
58
62
  * Coalesced prospective `shared`-mode prefix set (the result of running
59
63
  * the caller's explicit grants through `coalescePrefixes`). Prefixes are
60
- * hq-root-relative (e.g. `companies/indigo/meetings/`).
64
+ * COMPANY-RELATIVE (e.g. `meetings/`) — the namespace the grants endpoint
65
+ * returns and the namespace `relPath` is now computed in.
61
66
  */
62
67
  prospectivePrefixSet: readonly string[];
63
68
  /**