@indigoai-us/hq-cli 5.144.0 → 5.145.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/CHANGELOG.md +8 -0
- package/dist/command-catalog.generated.d.ts +6 -6
- package/dist/command-catalog.generated.js +6 -6
- package/dist/commands/_patterns.d.ts +16 -0
- package/dist/commands/_patterns.js +20 -3
- package/dist/commands/files-browse.d.ts +21 -10
- package/dist/commands/files-browse.js +95 -32
- package/dist/commands/files.js +44 -13
- package/dist/commands/report.d.ts +7 -0
- package/dist/commands/report.js +12 -8
- package/dist/commands/sync-narrow.js +36 -5
- package/dist/commands/workers.js +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.145.0] — 2026-09-23
|
|
6
|
+
|
|
7
|
+
- `hq files` now preserves a trailing-slash private-folder pattern such as
|
|
8
|
+
`foo/` rather than rewriting it to `foo/*`, identifies private folders in
|
|
9
|
+
ACL and discovery output, and explains the conflict with an existing shared
|
|
10
|
+
glob. Quote a shared glob such as `'foo/*'` when invoking it from a shell.
|
|
11
|
+
- `hq report` now finds reports on project channels. It listed only personal channels and treated only company-wide channels as valid, so every publish from a bot on a project channel failed with a generic deploy error. Channel lookup errors now say what went wrong.
|
|
12
|
+
|
|
5
13
|
## [5.144.0] — 2026-09-23
|
|
6
14
|
|
|
7
15
|
- Worker-profile models now use the same provider catalog validation as
|
|
@@ -1642,7 +1642,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1642
1642
|
}];
|
|
1643
1643
|
}, {
|
|
1644
1644
|
readonly name: "files";
|
|
1645
|
-
readonly description: "Manage file access controls in HQ vault";
|
|
1645
|
+
readonly description: "Manage file access controls in HQ vault. A trailing folder slash (foo/) is a private folder; the quoted glob 'foo/*' shares all children.";
|
|
1646
1646
|
readonly aliases: readonly [];
|
|
1647
1647
|
readonly hidden: false;
|
|
1648
1648
|
readonly usage: "[options] [command]";
|
|
@@ -1653,7 +1653,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1653
1653
|
}];
|
|
1654
1654
|
readonly subcommands: readonly [{
|
|
1655
1655
|
readonly name: "share";
|
|
1656
|
-
readonly description: "Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.";
|
|
1656
|
+
readonly description: "Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.";
|
|
1657
1657
|
readonly aliases: readonly [];
|
|
1658
1658
|
readonly hidden: false;
|
|
1659
1659
|
readonly usage: "[options] [paths...]";
|
|
@@ -1687,7 +1687,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1687
1687
|
readonly subcommands: readonly [];
|
|
1688
1688
|
}, {
|
|
1689
1689
|
readonly name: "unshare";
|
|
1690
|
-
readonly description: "Remove a file access grant";
|
|
1690
|
+
readonly description: "Remove a file access grant. A trailing slash targets the private-folder row exactly.";
|
|
1691
1691
|
readonly aliases: readonly [];
|
|
1692
1692
|
readonly hidden: false;
|
|
1693
1693
|
readonly usage: "[options] <prefix>";
|
|
@@ -1703,7 +1703,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1703
1703
|
readonly subcommands: readonly [];
|
|
1704
1704
|
}, {
|
|
1705
1705
|
readonly name: "acl";
|
|
1706
|
-
readonly description: "Show the ACL (access control list) for a file prefix";
|
|
1706
|
+
readonly description: "Show the ACL (access control list) for a file prefix. foo/ is a private folder whose children are creator-only; the quoted glob 'foo/*' is shared.";
|
|
1707
1707
|
readonly aliases: readonly [];
|
|
1708
1708
|
readonly hidden: false;
|
|
1709
1709
|
readonly usage: "[options] <prefix>";
|
|
@@ -1797,7 +1797,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1797
1797
|
readonly subcommands: readonly [];
|
|
1798
1798
|
}, {
|
|
1799
1799
|
readonly name: "browse";
|
|
1800
|
-
readonly description: "List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.";
|
|
1800
|
+
readonly description: "List vault objects under [path] without syncing them locally. Private-folder rows (foo/) are labelled and do not imply child access; their children are creator-only. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.";
|
|
1801
1801
|
readonly aliases: readonly [];
|
|
1802
1802
|
readonly hidden: false;
|
|
1803
1803
|
readonly usage: "[options] [path]";
|
|
@@ -1844,7 +1844,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1844
1844
|
readonly subcommands: readonly [];
|
|
1845
1845
|
}, {
|
|
1846
1846
|
readonly name: "shared-with-me";
|
|
1847
|
-
readonly 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).";
|
|
1847
|
+
readonly description: "List the files/prefixes explicitly shared with you. Private folders (foo/) expose only the folder; children are creator-only. 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).";
|
|
1848
1848
|
readonly aliases: readonly [];
|
|
1849
1849
|
readonly hidden: false;
|
|
1850
1850
|
readonly usage: "[options]";
|
|
@@ -2093,7 +2093,7 @@ export const COMMAND_CATALOG = [
|
|
|
2093
2093
|
},
|
|
2094
2094
|
{
|
|
2095
2095
|
"name": "files",
|
|
2096
|
-
"description": "Manage file access controls in HQ vault",
|
|
2096
|
+
"description": "Manage file access controls in HQ vault. A trailing folder slash (foo/) is a private folder; the quoted glob 'foo/*' shares all children.",
|
|
2097
2097
|
"aliases": [],
|
|
2098
2098
|
"hidden": false,
|
|
2099
2099
|
"usage": "[options] [command]",
|
|
@@ -2107,7 +2107,7 @@ export const COMMAND_CATALOG = [
|
|
|
2107
2107
|
"subcommands": [
|
|
2108
2108
|
{
|
|
2109
2109
|
"name": "share",
|
|
2110
|
-
"description": "Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.",
|
|
2110
|
+
"description": "Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.",
|
|
2111
2111
|
"aliases": [],
|
|
2112
2112
|
"hidden": false,
|
|
2113
2113
|
"usage": "[options] [paths...]",
|
|
@@ -2152,7 +2152,7 @@ export const COMMAND_CATALOG = [
|
|
|
2152
2152
|
},
|
|
2153
2153
|
{
|
|
2154
2154
|
"name": "unshare",
|
|
2155
|
-
"description": "Remove a file access grant",
|
|
2155
|
+
"description": "Remove a file access grant. A trailing slash targets the private-folder row exactly.",
|
|
2156
2156
|
"aliases": [],
|
|
2157
2157
|
"hidden": false,
|
|
2158
2158
|
"usage": "[options] <prefix>",
|
|
@@ -2173,7 +2173,7 @@ export const COMMAND_CATALOG = [
|
|
|
2173
2173
|
},
|
|
2174
2174
|
{
|
|
2175
2175
|
"name": "acl",
|
|
2176
|
-
"description": "Show the ACL (access control list) for a file prefix",
|
|
2176
|
+
"description": "Show the ACL (access control list) for a file prefix. foo/ is a private folder whose children are creator-only; the quoted glob 'foo/*' is shared.",
|
|
2177
2177
|
"aliases": [],
|
|
2178
2178
|
"hidden": false,
|
|
2179
2179
|
"usage": "[options] <prefix>",
|
|
@@ -2296,7 +2296,7 @@ export const COMMAND_CATALOG = [
|
|
|
2296
2296
|
},
|
|
2297
2297
|
{
|
|
2298
2298
|
"name": "browse",
|
|
2299
|
-
"description": "List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.",
|
|
2299
|
+
"description": "List vault objects under [path] without syncing them locally. Private-folder rows (foo/) are labelled and do not imply child access; their children are creator-only. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.",
|
|
2300
2300
|
"aliases": [],
|
|
2301
2301
|
"hidden": false,
|
|
2302
2302
|
"usage": "[options] [path]",
|
|
@@ -2358,7 +2358,7 @@ export const COMMAND_CATALOG = [
|
|
|
2358
2358
|
},
|
|
2359
2359
|
{
|
|
2360
2360
|
"name": "shared-with-me",
|
|
2361
|
-
"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).",
|
|
2361
|
+
"description": "List the files/prefixes explicitly shared with you. Private folders (foo/) expose only the folder; children are creator-only. 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).",
|
|
2362
2362
|
"aliases": [],
|
|
2363
2363
|
"hidden": false,
|
|
2364
2364
|
"usage": "[options]",
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
export declare const SECRET_NAME_PATTERN: RegExp;
|
|
2
2
|
export declare const GROUP_ID_PATTERN: RegExp;
|
|
3
3
|
export declare const EMAIL_PATTERN: RegExp;
|
|
4
|
+
/**
|
|
5
|
+
* Keep an ACL pattern exactly as the caller supplied it.
|
|
6
|
+
*
|
|
7
|
+
* `foo/` is a private folder row: it authorizes creating a direct child, but
|
|
8
|
+
* does not grant access to any child. `foo/*` is the distinct shared-folder
|
|
9
|
+
* glob. The server owns validation (including the conflict between them), so
|
|
10
|
+
* the CLI must never silently turn one into the other.
|
|
11
|
+
*/
|
|
4
12
|
export declare function normalizeFilePrefix(prefix: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Make a recursive folder glob only where the caller intentionally asks for
|
|
15
|
+
* one. This is not an ACL-input normalizer: callers must opt in because a
|
|
16
|
+
* trailing slash has private-folder semantics on ACL routes.
|
|
17
|
+
*/
|
|
18
|
+
export declare function toSharedFolderGlob(prefix: string): string;
|
|
19
|
+
/** A non-root trailing slash is the private-folder ACL pattern. */
|
|
20
|
+
export declare function isPrivateFolderPattern(prefix: string): boolean;
|
|
5
21
|
//# sourceMappingURL=_patterns.d.ts.map
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
export const SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*$/;
|
|
2
2
|
export const GROUP_ID_PATTERN = /^grp_[A-Za-z0-9_-]+$/;
|
|
3
3
|
export const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
4
|
+
/**
|
|
5
|
+
* Keep an ACL pattern exactly as the caller supplied it.
|
|
6
|
+
*
|
|
7
|
+
* `foo/` is a private folder row: it authorizes creating a direct child, but
|
|
8
|
+
* does not grant access to any child. `foo/*` is the distinct shared-folder
|
|
9
|
+
* glob. The server owns validation (including the conflict between them), so
|
|
10
|
+
* the CLI must never silently turn one into the other.
|
|
11
|
+
*/
|
|
4
12
|
export function normalizeFilePrefix(prefix) {
|
|
5
|
-
if (prefix.endsWith("/")) {
|
|
6
|
-
return prefix + "*";
|
|
7
|
-
}
|
|
8
13
|
return prefix;
|
|
9
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Make a recursive folder glob only where the caller intentionally asks for
|
|
17
|
+
* one. This is not an ACL-input normalizer: callers must opt in because a
|
|
18
|
+
* trailing slash has private-folder semantics on ACL routes.
|
|
19
|
+
*/
|
|
20
|
+
export function toSharedFolderGlob(prefix) {
|
|
21
|
+
return prefix.endsWith("/") ? `${prefix}*` : prefix;
|
|
22
|
+
}
|
|
23
|
+
/** A non-root trailing slash is the private-folder ACL pattern. */
|
|
24
|
+
export function isPrivateFolderPattern(prefix) {
|
|
25
|
+
return prefix.length > 1 && prefix.endsWith("/");
|
|
26
|
+
}
|
|
10
27
|
//# sourceMappingURL=_patterns.js.map
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* `toCompanyAnchored`.
|
|
32
32
|
*
|
|
33
33
|
* Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
|
|
34
|
-
* methods
|
|
34
|
+
* methods from hq-cloud.
|
|
35
35
|
*/
|
|
36
36
|
import { Command } from "commander";
|
|
37
37
|
import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
|
|
@@ -110,14 +110,18 @@ export type CompanyBrowseClientFactory = (input: {
|
|
|
110
110
|
/**
|
|
111
111
|
* ACL provenance for a single listed key.
|
|
112
112
|
* - `shared-with-you`: an explicit grant the caller holds covers the key.
|
|
113
|
+
* - `creator`: a creator-only child grant (or its folder marker) covers the
|
|
114
|
+
* key; this is explicit creator access, not an owner/admin role bypass.
|
|
113
115
|
* - `role-bypass`: the caller has no covering explicit grant, but
|
|
114
116
|
* owner/admin role widened the browse-vend policy to include it.
|
|
115
117
|
* - `personal-vault`: the key lives in the caller's own person-entity
|
|
116
118
|
* vault, where no grants graph applies — the caller is the only
|
|
117
119
|
* principal with access by construction. Emitted only when
|
|
118
120
|
* `runBrowse({ personalMode: true })`.
|
|
121
|
+
* - `private-folder`: this is the private folder row itself. Its children
|
|
122
|
+
* are creator-only, so it never covers a child key.
|
|
119
123
|
*/
|
|
120
|
-
export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
|
|
124
|
+
export type AclSource = "shared-with-you" | "creator" | "role-bypass" | "personal-vault" | "private-folder";
|
|
121
125
|
export interface BrowseRow {
|
|
122
126
|
key: string;
|
|
123
127
|
size: number;
|
|
@@ -148,16 +152,23 @@ export declare function toBucketRelative(pathOrPrefix: string, slug: string): st
|
|
|
148
152
|
export declare function toCompanyAnchored(bucketRelKey: string, slug: string): string;
|
|
149
153
|
/**
|
|
150
154
|
* Classify a single company-relative S3 key against the caller's
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* the owner/admin role-bypass the `/sts/vend`
|
|
154
|
-
* prefix is a company-wide grant and matches
|
|
155
|
+
* raw explicit-grant patterns. `foo/*` covers all keys below `foo/`, while
|
|
156
|
+
* `foo/` is the private folder itself and never covers a child key. Otherwise
|
|
157
|
+
* the key is visible only via the owner/admin role-bypass the `/sts/vend`
|
|
158
|
+
* policy applied. An empty-string prefix is a company-wide grant and matches
|
|
159
|
+
* everything.
|
|
155
160
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
161
|
+
* The caller translates only the redundant company anchor before calling this;
|
|
162
|
+
* it must not coalesce a trailing slash into a glob, which would imply child
|
|
163
|
+
* access that the server did not grant.
|
|
159
164
|
*/
|
|
160
|
-
export declare function classifyAclSource(bucketRelKey: string,
|
|
165
|
+
export declare function classifyAclSource(bucketRelKey: string, grantPatterns: Array<string | BrowseGrantPattern>): AclSource;
|
|
166
|
+
export interface BrowseGrantPattern {
|
|
167
|
+
pattern: string;
|
|
168
|
+
source?: ExplicitGrant["source"];
|
|
169
|
+
}
|
|
170
|
+
/** Remove only the redundant company anchor; retain the ACL pattern itself. */
|
|
171
|
+
export declare function toBrowseGrantPattern(grantPath: string, slug: string): string;
|
|
161
172
|
/**
|
|
162
173
|
* Bright-line guard for `--out`: refuse to write any byte beneath
|
|
163
174
|
* `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* `toCompanyAnchored`.
|
|
32
32
|
*
|
|
33
33
|
* Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
|
|
34
|
-
* methods
|
|
34
|
+
* methods from hq-cloud.
|
|
35
35
|
*/
|
|
36
36
|
import { isAccessDeniedError, accessDeniedKeyOf, reportAccessDenied, accessDeniedCompanyOf, } from "../utils/access-denied-hint.js";
|
|
37
37
|
import chalk from "chalk";
|
|
@@ -40,7 +40,8 @@ import * as path from "node:path";
|
|
|
40
40
|
import { Readable } from "node:stream";
|
|
41
41
|
import { pipeline } from "node:stream/promises";
|
|
42
42
|
import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
|
|
43
|
-
import { VaultClient,
|
|
43
|
+
import { VaultClient, } from "@indigoai-us/hq-cloud";
|
|
44
|
+
import { isPrivateFolderPattern } from "./_patterns.js";
|
|
44
45
|
import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
45
46
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
46
47
|
import { getCompanyUid, looksLikeCompanyUid, looksLikePersonVaultUid, vaultApiFetch, } from "../utils/vault-api.js";
|
|
@@ -70,9 +71,15 @@ export function parseCompanySlugFromPath(prefix) {
|
|
|
70
71
|
*/
|
|
71
72
|
export function toBucketRelative(pathOrPrefix, slug) {
|
|
72
73
|
const anchor = `companies/${slug}/`;
|
|
74
|
+
const slugAnchor = `${slug}/`;
|
|
73
75
|
const normalized = pathOrPrefix.replace(/^\/+/, "");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
if (normalized.startsWith(anchor))
|
|
77
|
+
return normalized.slice(anchor.length);
|
|
78
|
+
// Explicit-grant responses can use either the full company anchor or the
|
|
79
|
+
// historical bare-slug anchor. Strip both before classification, while
|
|
80
|
+
// preserving the caller's trailing `/` versus `/*` semantics.
|
|
81
|
+
return normalized.startsWith(slugAnchor)
|
|
82
|
+
? normalized.slice(slugAnchor.length)
|
|
76
83
|
: normalized;
|
|
77
84
|
}
|
|
78
85
|
/**
|
|
@@ -85,22 +92,62 @@ export function toCompanyAnchored(bucketRelKey, slug) {
|
|
|
85
92
|
}
|
|
86
93
|
/**
|
|
87
94
|
* Classify a single company-relative S3 key against the caller's
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* the owner/admin role-bypass the `/sts/vend`
|
|
91
|
-
* prefix is a company-wide grant and matches
|
|
95
|
+
* raw explicit-grant patterns. `foo/*` covers all keys below `foo/`, while
|
|
96
|
+
* `foo/` is the private folder itself and never covers a child key. Otherwise
|
|
97
|
+
* the key is visible only via the owner/admin role-bypass the `/sts/vend`
|
|
98
|
+
* policy applied. An empty-string prefix is a company-wide grant and matches
|
|
99
|
+
* everything.
|
|
92
100
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
101
|
+
* The caller translates only the redundant company anchor before calling this;
|
|
102
|
+
* it must not coalesce a trailing slash into a glob, which would imply child
|
|
103
|
+
* access that the server did not grant.
|
|
96
104
|
*/
|
|
97
|
-
export function classifyAclSource(bucketRelKey,
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
export function classifyAclSource(bucketRelKey, grantPatterns) {
|
|
106
|
+
const normalized = grantPatterns.map((entry) => typeof entry === "string"
|
|
107
|
+
? { pattern: entry, source: undefined }
|
|
108
|
+
: entry);
|
|
109
|
+
for (const entry of normalized) {
|
|
110
|
+
const pattern = entry.pattern;
|
|
111
|
+
if (pattern === "" || pattern === "*")
|
|
100
112
|
return "shared-with-you";
|
|
113
|
+
if (pattern.endsWith("/*")) {
|
|
114
|
+
if (bucketRelKey.startsWith(pattern.slice(0, -1))) {
|
|
115
|
+
return entry.source === "creator" ? "creator" : "shared-with-you";
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (isPrivateFolderPattern(pattern)) {
|
|
120
|
+
if (bucketRelKey === pattern)
|
|
121
|
+
return "private-folder";
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (bucketRelKey === pattern) {
|
|
125
|
+
return entry.source === "creator" ? "creator" : "shared-with-you";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// The server can expose a creator-owned child row without returning the
|
|
129
|
+
// creator's original `foo/` row. Keep the zero-byte `foo/` marker visible
|
|
130
|
+
// and label it as creator access when a creator grant is underneath it.
|
|
131
|
+
if (bucketRelKey.endsWith("/")) {
|
|
132
|
+
const creatorOwnsMarker = normalized.some((entry) => entry.source === "creator" &&
|
|
133
|
+
entry.pattern.startsWith(bucketRelKey));
|
|
134
|
+
if (creatorOwnsMarker)
|
|
135
|
+
return "creator";
|
|
101
136
|
}
|
|
102
137
|
return "role-bypass";
|
|
103
138
|
}
|
|
139
|
+
/** Remove only the redundant company anchor; retain the ACL pattern itself. */
|
|
140
|
+
export function toBrowseGrantPattern(grantPath, slug) {
|
|
141
|
+
const companyRelative = toBucketRelative(grantPath, slug);
|
|
142
|
+
return companyRelative === "*" ? "" : companyRelative;
|
|
143
|
+
}
|
|
144
|
+
function displayAclSource(source) {
|
|
145
|
+
return source === "private-folder"
|
|
146
|
+
? "private folder — children are creator-only"
|
|
147
|
+
: source === "creator"
|
|
148
|
+
? "creator access — creator-only"
|
|
149
|
+
: source;
|
|
150
|
+
}
|
|
104
151
|
/**
|
|
105
152
|
* Bright-line guard for `--out`: refuse to write any byte beneath
|
|
106
153
|
* `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
|
|
@@ -132,7 +179,7 @@ export function formatBrowseTable(rows) {
|
|
|
132
179
|
r.key,
|
|
133
180
|
String(r.size),
|
|
134
181
|
r.lastModified ? r.lastModified.toISOString() : "—",
|
|
135
|
-
r.aclSource,
|
|
182
|
+
displayAclSource(r.aclSource),
|
|
136
183
|
]);
|
|
137
184
|
const widths = cols.map((c, i) => Math.max(c.length, ...data.map((row) => row[i].length)));
|
|
138
185
|
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
@@ -225,14 +272,17 @@ export async function runBrowse(input) {
|
|
|
225
272
|
const listPrefix = personalMode || slug === undefined
|
|
226
273
|
? pathPrefix
|
|
227
274
|
: toBucketRelative(pathPrefix, slug);
|
|
228
|
-
// Pull the caller's explicit-grant graph once so per-key classification
|
|
229
|
-
//
|
|
230
|
-
// company
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
const
|
|
275
|
+
// Pull the caller's explicit-grant graph once so per-key classification is
|
|
276
|
+
// O(grants) without N round-trips. Retain the raw pattern after stripping
|
|
277
|
+
// only the redundant company anchor: `foo/` and `foo/*` have different
|
|
278
|
+
// server semantics. Skipped in personal mode — the grants graph is a company
|
|
279
|
+
// concept; a person bucket marks every row `"personal-vault"`.
|
|
280
|
+
const grantPatterns = personalMode || slug === undefined
|
|
234
281
|
? []
|
|
235
|
-
: (await vaultClient.listMyExplicitGrants(entityUid)).map((g) =>
|
|
282
|
+
: (await vaultClient.listMyExplicitGrants(entityUid)).map((g) => ({
|
|
283
|
+
pattern: toBrowseGrantPattern(g.path, slug),
|
|
284
|
+
source: g.source,
|
|
285
|
+
}));
|
|
236
286
|
const rows = [];
|
|
237
287
|
let continuationToken;
|
|
238
288
|
do {
|
|
@@ -244,8 +294,14 @@ export async function runBrowse(input) {
|
|
|
244
294
|
for (const obj of resp.Contents ?? []) {
|
|
245
295
|
if (!obj.Key)
|
|
246
296
|
continue;
|
|
247
|
-
|
|
248
|
-
|
|
297
|
+
const aclSource = personalMode
|
|
298
|
+
? "personal-vault"
|
|
299
|
+
: classifyAclSource(obj.Key, grantPatterns);
|
|
300
|
+
// Keep a private-folder marker visible so its ACL meaning can be shown.
|
|
301
|
+
// Other zero-byte directory markers remain list implementation details.
|
|
302
|
+
if (obj.Key.endsWith("/") &&
|
|
303
|
+
(obj.Size ?? 0) === 0 &&
|
|
304
|
+
aclSource !== "private-folder")
|
|
249
305
|
continue;
|
|
250
306
|
// `obj.Key` is company-relative. Classify in that space, then re-anchor
|
|
251
307
|
// for display so the CLI keeps speaking `companies/<slug>/...`.
|
|
@@ -255,9 +311,7 @@ export async function runBrowse(input) {
|
|
|
255
311
|
: toCompanyAnchored(obj.Key, slug),
|
|
256
312
|
size: obj.Size ?? 0,
|
|
257
313
|
lastModified: obj.LastModified,
|
|
258
|
-
aclSource
|
|
259
|
-
? "personal-vault"
|
|
260
|
-
: classifyAclSource(obj.Key, grantPrefixes),
|
|
314
|
+
aclSource,
|
|
261
315
|
});
|
|
262
316
|
}
|
|
263
317
|
continuationToken = resp.NextContinuationToken ?? undefined;
|
|
@@ -434,14 +488,23 @@ export function formatSharedWithMeTable(rows) {
|
|
|
434
488
|
return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
|
|
435
489
|
}
|
|
436
490
|
const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
|
|
437
|
-
const data = rows.map((r) => [
|
|
491
|
+
const data = rows.map((r) => [
|
|
492
|
+
r.companySlug,
|
|
493
|
+
isPrivateFolderPattern(r.path) ? `${r.path} (private folder)` : r.path,
|
|
494
|
+
r.permission,
|
|
495
|
+
r.source,
|
|
496
|
+
]);
|
|
438
497
|
const widths = cols.map((c, i) => Math.max(c.length, ...data.map((row) => row[i].length)));
|
|
439
498
|
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
440
|
-
|
|
499
|
+
const lines = [
|
|
441
500
|
chalk.bold(renderRow(cols)),
|
|
442
501
|
chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
|
|
443
502
|
...data.map(renderRow),
|
|
444
|
-
]
|
|
503
|
+
];
|
|
504
|
+
if (rows.some((row) => isPrivateFolderPattern(row.path))) {
|
|
505
|
+
lines.push("Private folders expose the folder only; children are creator-only.");
|
|
506
|
+
}
|
|
507
|
+
return lines.join("\n");
|
|
445
508
|
}
|
|
446
509
|
/**
|
|
447
510
|
* `hq files search <query>` orchestrator. Lists the company (or personal)
|
|
@@ -863,7 +926,7 @@ export async function resolveBrowseSession() {
|
|
|
863
926
|
export function registerFilesBrowseCommands(filesCmd) {
|
|
864
927
|
filesCmd
|
|
865
928
|
.command("browse [path]")
|
|
866
|
-
.description("List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
|
|
929
|
+
.description("List vault objects under [path] without syncing them locally. Private-folder rows (foo/) are labelled and do not imply child access; their children are creator-only. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
|
|
867
930
|
.option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
|
|
868
931
|
.option("--personal", "Browse the caller's canonical personal vault. [path] is treated as " +
|
|
869
932
|
"bucket-relative (omit it to list the vault root). Mutually exclusive " +
|
|
@@ -1035,7 +1098,7 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
1035
1098
|
});
|
|
1036
1099
|
filesCmd
|
|
1037
1100
|
.command("shared-with-me")
|
|
1038
|
-
.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).")
|
|
1101
|
+
.description("List the files/prefixes explicitly shared with you. Private folders (foo/) expose only the folder; children are creator-only. 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).")
|
|
1039
1102
|
.option("--company <slug>", "Scope to a single company (defaults to a cross-company roll-up).")
|
|
1040
1103
|
.action(async (options, command) => {
|
|
1041
1104
|
try {
|
package/dist/commands/files.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as readline from "node:readline";
|
|
|
4
4
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
5
5
|
import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
|
|
6
6
|
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
7
|
-
import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
|
|
7
|
+
import { GROUP_ID_PATTERN, EMAIL_PATTERN, isPrivateFolderPattern, normalizeFilePrefix, toSharedFolderGlob, } from "./_patterns.js";
|
|
8
8
|
import { looksLikeCompanyUid } from "../utils/vault-api.js";
|
|
9
9
|
import { AuthError } from "../utils/auth-error.js";
|
|
10
10
|
import { formatAccessDenied, offerAccessLadder, } from "../utils/access-denied-hint.js";
|
|
@@ -40,6 +40,27 @@ export function parseDuration(input) {
|
|
|
40
40
|
}
|
|
41
41
|
/** PRD upper bound on browser-launch share-session expiry. */
|
|
42
42
|
export const MAX_SHARE_SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24h
|
|
43
|
+
/** Render the ACL-specific meaning of a path without changing its wire value. */
|
|
44
|
+
function displayAclPattern(prefix) {
|
|
45
|
+
return isPrivateFolderPattern(prefix)
|
|
46
|
+
? `${prefix} (private folder)`
|
|
47
|
+
: prefix;
|
|
48
|
+
}
|
|
49
|
+
function privateFolderExplanation(prefix) {
|
|
50
|
+
return (`Private folder '${prefix}' — write can create only a new direct child. ` +
|
|
51
|
+
"Children are creator-only; this grant does not read, list, overwrite, or delete them.");
|
|
52
|
+
}
|
|
53
|
+
function formatAclPatternConflict(prefix, serverMessage) {
|
|
54
|
+
const privatePattern = prefix.endsWith("/*")
|
|
55
|
+
? prefix.slice(0, -1)
|
|
56
|
+
: prefix.endsWith("/")
|
|
57
|
+
? prefix
|
|
58
|
+
: `${prefix}/`;
|
|
59
|
+
const sharedPattern = `${privatePattern}*`;
|
|
60
|
+
const detail = serverMessage ? `${serverMessage}. ` : "";
|
|
61
|
+
return (`${detail}A private folder '${privatePattern}' and shared folder glob ` +
|
|
62
|
+
`'${sharedPattern}' cannot both exist. Remove the conflicting ACL pattern and retry.`);
|
|
63
|
+
}
|
|
43
64
|
/**
|
|
44
65
|
* POST /files/{companyUid}/share-session — mint a browser-launch share
|
|
45
66
|
* session token. Returns the parsed response. Throws ShareSessionHttpError
|
|
@@ -101,7 +122,7 @@ export function formatShareSessionError(err) {
|
|
|
101
122
|
export function registerFilesCommand(program) {
|
|
102
123
|
const files = program
|
|
103
124
|
.command("files")
|
|
104
|
-
.description("Manage file access controls in HQ vault")
|
|
125
|
+
.description("Manage file access controls in HQ vault. A trailing folder slash (foo/) is a private folder; the quoted glob 'foo/*' shares all children.")
|
|
105
126
|
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
106
127
|
// An exported HQ_API_KEY must never fall back to the caller's Cognito session
|
|
107
128
|
// (that would ignore the key's scope entirely), so every subcommand is gated.
|
|
@@ -130,7 +151,7 @@ export function registerFilesCommand(program) {
|
|
|
130
151
|
});
|
|
131
152
|
files
|
|
132
153
|
.command("share [paths...]")
|
|
133
|
-
.description("Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
|
|
154
|
+
.description("Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
|
|
134
155
|
.option("--with <principal>", "Email address, group id, or '@all' to share with every active company member")
|
|
135
156
|
.option("--permission <level>", "Permission level (only with --with): read | write")
|
|
136
157
|
.option("--full", "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.")
|
|
@@ -239,7 +260,7 @@ export function registerFilesCommand(program) {
|
|
|
239
260
|
});
|
|
240
261
|
files
|
|
241
262
|
.command("unshare <prefix>")
|
|
242
|
-
.description("Remove a file access grant")
|
|
263
|
+
.description("Remove a file access grant. A trailing slash targets the private-folder row exactly.")
|
|
243
264
|
.requiredOption("--with <principal>", "Email address, group id, or '@all' to remove the company-wide grant")
|
|
244
265
|
.action(async (prefix, opts) => {
|
|
245
266
|
try {
|
|
@@ -316,7 +337,7 @@ export function registerFilesCommand(program) {
|
|
|
316
337
|
}
|
|
317
338
|
process.exit(1);
|
|
318
339
|
}
|
|
319
|
-
console.log(chalk.green(`Removed grant for ${principalLabel} on '${canonicalPrefix}'`));
|
|
340
|
+
console.log(chalk.green(`Removed grant for ${principalLabel} on '${displayAclPattern(canonicalPrefix)}'`));
|
|
320
341
|
}
|
|
321
342
|
catch (err) {
|
|
322
343
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
@@ -325,7 +346,7 @@ export function registerFilesCommand(program) {
|
|
|
325
346
|
});
|
|
326
347
|
files
|
|
327
348
|
.command("acl <prefix>")
|
|
328
|
-
.description("Show the ACL (access control list) for a file prefix")
|
|
349
|
+
.description("Show the ACL (access control list) for a file prefix. foo/ is a private folder whose children are creator-only; the quoted glob 'foo/*' is shared.")
|
|
329
350
|
.option("--json", "Output structured ACL data, including explicit absence")
|
|
330
351
|
.action(async (prefix, opts) => {
|
|
331
352
|
try {
|
|
@@ -389,7 +410,10 @@ export function registerFilesCommand(program) {
|
|
|
389
410
|
process.exit(1);
|
|
390
411
|
}
|
|
391
412
|
const aclStatus = row?.open ? "open" : "restricted";
|
|
392
|
-
console.log(chalk.green(`ACL for ${tree.prefix} (${aclStatus})`));
|
|
413
|
+
console.log(chalk.green(`ACL for ${displayAclPattern(tree.prefix)} (${aclStatus})`));
|
|
414
|
+
if (isPrivateFolderPattern(tree.prefix)) {
|
|
415
|
+
console.log(chalk.gray(privateFolderExplanation(tree.prefix)));
|
|
416
|
+
}
|
|
393
417
|
if (row) {
|
|
394
418
|
console.log(`Creator: ${row.creatorUid}`);
|
|
395
419
|
}
|
|
@@ -413,7 +437,7 @@ export function registerFilesCommand(program) {
|
|
|
413
437
|
const PERM_W = Math.max(10, ...rows.map((e) => e.permission.length));
|
|
414
438
|
const BY_W = Math.max(10, ...rows.map((e) => e.grantedBy.length));
|
|
415
439
|
const SRC_W = showSource
|
|
416
|
-
? Math.max(6, ...rows.map((e) => (e.sourcePrefix ?? "").length))
|
|
440
|
+
? Math.max(6, ...rows.map((e) => displayAclPattern(e.sourcePrefix ?? "").length))
|
|
417
441
|
: 0;
|
|
418
442
|
const headerCols = [
|
|
419
443
|
"TYPE".padEnd(TYPE_W),
|
|
@@ -435,7 +459,7 @@ export function registerFilesCommand(program) {
|
|
|
435
459
|
grantedAt,
|
|
436
460
|
];
|
|
437
461
|
if (showSource)
|
|
438
|
-
cols.splice(4, 0, (e.sourcePrefix ?? "").padEnd(SRC_W));
|
|
462
|
+
cols.splice(4, 0, displayAclPattern(e.sourcePrefix ?? "").padEnd(SRC_W));
|
|
439
463
|
console.log(cols.join(" "));
|
|
440
464
|
}
|
|
441
465
|
}
|
|
@@ -713,6 +737,9 @@ async function runDirectGrant(params) {
|
|
|
713
737
|
else if (res.status === 404) {
|
|
714
738
|
console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
|
|
715
739
|
}
|
|
740
|
+
else if (res.status === 409 && body.code === "ACL_PATTERN_CONFLICT") {
|
|
741
|
+
console.error(chalk.red(formatAclPatternConflict(canonicalPrefix, body.message)));
|
|
742
|
+
}
|
|
716
743
|
else if (res.status === 409) {
|
|
717
744
|
console.error(chalk.red("Concurrent modification — please retry"));
|
|
718
745
|
}
|
|
@@ -731,7 +758,10 @@ async function runDirectGrant(params) {
|
|
|
731
758
|
console.log(chalk.green(`${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`));
|
|
732
759
|
}
|
|
733
760
|
else {
|
|
734
|
-
console.log(chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`));
|
|
761
|
+
console.log(chalk.green(`${verb} ${params.permission} on ${displayAclPattern(printedPrefix)} to ${principalLabel}`));
|
|
762
|
+
}
|
|
763
|
+
if (isPrivateFolderPattern(printedPrefix)) {
|
|
764
|
+
console.log(chalk.gray(privateFolderExplanation(printedPrefix)));
|
|
735
765
|
}
|
|
736
766
|
if (typeof data.record?.error === "string" &&
|
|
737
767
|
typeof data.record.status === "number" &&
|
|
@@ -1049,19 +1079,20 @@ export async function runFilesDelete(params, deps = {}) {
|
|
|
1049
1079
|
const confirm = deps.confirm ?? realConfirm;
|
|
1050
1080
|
// Strip the local company anchor before preview/confirmation (HQ-8F/HQ-CA).
|
|
1051
1081
|
// Personal keys are already bucket-relative and must keep any such anchor.
|
|
1052
|
-
//
|
|
1082
|
+
// Deletion intentionally expands a folder spelling to a recursive glob, then
|
|
1083
|
+
// rejects the root/empty prefix before
|
|
1053
1084
|
// any network call, including when stripping a bare company anchor empties it.
|
|
1054
1085
|
// The anchor names a company. Compare it with --company (refusing a
|
|
1055
1086
|
// mismatch), and when --company was omitted ADOPT it rather than letting the
|
|
1056
1087
|
// membership fallback pick a different company to delete from.
|
|
1057
1088
|
const anchorSlug = companySlugFromAnchor(params.prefix, params.personal);
|
|
1058
|
-
const normalized = normalizeCompanyFilePrefix(params.prefix, {
|
|
1089
|
+
const normalized = toSharedFolderGlob(normalizeCompanyFilePrefix(params.prefix, {
|
|
1059
1090
|
personal: params.personal,
|
|
1060
1091
|
deferRootReject: true,
|
|
1061
1092
|
...(params.companySlug !== undefined
|
|
1062
1093
|
? { companySlug: params.companySlug }
|
|
1063
1094
|
: {}),
|
|
1064
|
-
});
|
|
1095
|
+
}));
|
|
1065
1096
|
if (normalized === "" || normalized === "*" || normalized === "/*") {
|
|
1066
1097
|
console.error(chalk.red("Refusing to delete the vault root. Pass a bounded prefix (e.g. 'projects/foo/' or 'projects/foo/*') or an exact key."));
|
|
1067
1098
|
process.exit(1);
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { buildReportTarball, deployOrgContext, type ChannelReport, type DeployAccessPolicy, type ReportDeployment, type ReportPatch } from "../lib/report-publish.js";
|
|
3
|
+
type ChannelRow = {
|
|
4
|
+
channelId?: string;
|
|
5
|
+
scope?: string;
|
|
6
|
+
companyUid?: string | null;
|
|
7
|
+
};
|
|
3
8
|
type ReportPatchInput = ReportPatch | ((current: ChannelReport) => ReportPatch);
|
|
9
|
+
/** Any company-owned channel (company or project scope) can carry reports; personal channels cannot. */
|
|
10
|
+
export declare function isCompanyReportChannel(channel: ChannelRow): boolean;
|
|
4
11
|
/** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
|
|
5
12
|
export declare function resolveReport(token: string, reportId: string, requestedChannelId?: string): Promise<ChannelReport>;
|
|
6
13
|
export declare function patchReport(token: string, report: ChannelReport, patch: ReportPatchInput): Promise<ChannelReport>;
|
package/dist/commands/report.js
CHANGED
|
@@ -87,22 +87,26 @@ function print(value, json) {
|
|
|
87
87
|
console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
|
88
88
|
}
|
|
89
89
|
async function listReportChannels(token) {
|
|
90
|
-
|
|
90
|
+
// scope=all: the default list is personal channels only, which hid every
|
|
91
|
+
// project and company channel a report can live on.
|
|
92
|
+
const channels = await vaultApiFetch({ token, path: "/v1/notify/channels?scope=all" });
|
|
91
93
|
if (!channels.ok)
|
|
92
|
-
throw new ReportPublishError("Could not resolve the report channel.");
|
|
94
|
+
throw new ReportPublishError("Could not resolve the report channel.", "source-invalid");
|
|
93
95
|
const listed = await channels.json();
|
|
94
96
|
return Array.isArray(listed.channels) ? listed.channels : [];
|
|
95
97
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
/** Any company-owned channel (company or project scope) can carry reports; personal channels cannot. */
|
|
99
|
+
export function isCompanyReportChannel(channel) {
|
|
100
|
+
if (channel.scope === "personal")
|
|
101
|
+
return false;
|
|
102
|
+
return channel.scope === "company" || channel.scope === "project" || Boolean(channel.companyUid);
|
|
99
103
|
}
|
|
100
104
|
function requireCompanyReportChannel(channels, channelId) {
|
|
101
105
|
const channel = channels.find((row) => row.channelId === channelId);
|
|
102
106
|
if (!channel)
|
|
103
|
-
throw new ReportPublishError("Report channel was not found in your channels.");
|
|
107
|
+
throw new ReportPublishError("Report channel was not found in your channels.", "source-invalid");
|
|
104
108
|
if (!isCompanyReportChannel(channel))
|
|
105
|
-
throw new ReportPublishError("Channel reports require a company channel.");
|
|
109
|
+
throw new ReportPublishError("Channel reports require a company channel.", "source-invalid");
|
|
106
110
|
}
|
|
107
111
|
async function readReportInChannel(token, channelId, reportId) {
|
|
108
112
|
const res = await vaultApiFetch({ token, path: reportPath(channelId, reportId) });
|
|
@@ -121,7 +125,7 @@ export async function resolveReport(token, reportId, requestedChannelId) {
|
|
|
121
125
|
requireCompanyReportChannel(channels, explicit);
|
|
122
126
|
const res = await vaultApiFetch({ token, path: reportPath(explicit, reportId) });
|
|
123
127
|
if (!res.ok)
|
|
124
|
-
throw new ReportPublishError("Report was not found in the supplied channel context.");
|
|
128
|
+
throw new ReportPublishError("Report was not found in the supplied channel context.", "source-invalid");
|
|
125
129
|
const body = await res.json();
|
|
126
130
|
if (!body.report)
|
|
127
131
|
throw new ReportPublishError("Report response was incomplete.");
|
|
@@ -103,6 +103,31 @@ export async function resolveNarrowTarget(input) {
|
|
|
103
103
|
syncConfig,
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Normalize one ACL row for the local pull scope used by `sync narrow`.
|
|
108
|
+
*
|
|
109
|
+
* A trailing slash is a private-folder row, not a recursive read prefix. It
|
|
110
|
+
* lets its grantee create a new direct child, but existing children remain
|
|
111
|
+
* creator-only. Dropping that row from the prospective shared scope keeps the
|
|
112
|
+
* narrow plan from retaining private children merely because their parent
|
|
113
|
+
* happens to end in `/`. A creator's exact/child grant is still retained by
|
|
114
|
+
* the normal `grantPathToPrefix` path below.
|
|
115
|
+
*/
|
|
116
|
+
function narrowPrefixForGrant(grant, companySlug) {
|
|
117
|
+
let path = (grant.path ?? "").replace(/^\/+/, "");
|
|
118
|
+
const companyAnchor = `companies/${companySlug}/`;
|
|
119
|
+
const slugAnchor = `${companySlug}/`;
|
|
120
|
+
if (path.startsWith(companyAnchor)) {
|
|
121
|
+
path = path.slice(companyAnchor.length);
|
|
122
|
+
}
|
|
123
|
+
else if (path.startsWith(slugAnchor)) {
|
|
124
|
+
path = path.slice(slugAnchor.length);
|
|
125
|
+
}
|
|
126
|
+
if (path.length > 0 && path.endsWith("/") && !path.endsWith("/*")) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return grantPathToPrefix(grant.path, companySlug);
|
|
130
|
+
}
|
|
106
131
|
/**
|
|
107
132
|
* Compute the narrow plan for one company: fetch explicit grants, coalesce,
|
|
108
133
|
* read the per-company journal, walk the local tree, classify.
|
|
@@ -118,11 +143,17 @@ export async function computeNarrowPlan(input) {
|
|
|
118
143
|
// (grantPathToPrefix, hq-cloud ≥5.42.0): real grants are anchored
|
|
119
144
|
// (`companies/<slug>/x/*`, `<slug>/x/*`) and glob-style (`x/*`, bare `*`),
|
|
120
145
|
// none of which startsWith-match the company-relative local-tree keys
|
|
121
|
-
// buildNarrowPlan emits.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
146
|
+
// buildNarrowPlan emits. Private-folder rows (`x/`) are deliberately
|
|
147
|
+
// excluded: they do not grant read/list access to existing children, so
|
|
148
|
+
// treating them as recursive prefixes would leave private files on disk
|
|
149
|
+
// after an all → shared migration. A wildcard grant normalizes to ""
|
|
150
|
+
// (everything); coalescePrefixes drops empties, so guard it explicitly to
|
|
151
|
+
// `[""]` (which isCoveredByAny treats as covering everything → nothing
|
|
152
|
+
// orphaned) rather than letting it collapse to "nothing" and propose
|
|
153
|
+
// deleting the tree.
|
|
154
|
+
const normalizedPrefixes = grants
|
|
155
|
+
.map((g) => narrowPrefixForGrant(g, companySlug))
|
|
156
|
+
.filter((p) => p !== null);
|
|
126
157
|
const prospectivePrefixSet = normalizedPrefixes.some((p) => p === "")
|
|
127
158
|
? [""]
|
|
128
159
|
: coalescePrefixes(normalizedPrefixes);
|
package/dist/commands/workers.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as yaml from "js-yaml";
|
|
|
5
5
|
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
6
6
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
7
7
|
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
8
|
-
import { GROUP_ID_PATTERN, EMAIL_PATTERN,
|
|
8
|
+
import { GROUP_ID_PATTERN, EMAIL_PATTERN, toSharedFolderGlob } from "./_patterns.js";
|
|
9
9
|
import { findHqRoot } from "../utils/manifest.js";
|
|
10
10
|
export { readWorkerRegistry } from "../lib/workers-registry/read.js";
|
|
11
11
|
import { readWorkerRegistry } from "../lib/workers-registry/read.js";
|
|
@@ -56,7 +56,10 @@ export function workerVaultPrefix(w) {
|
|
|
56
56
|
const rel = w.path.startsWith(companyRoot)
|
|
57
57
|
? w.path.slice(companyRoot.length)
|
|
58
58
|
: w.path;
|
|
59
|
-
|
|
59
|
+
// Worker sharing intentionally covers every child in the generated worker
|
|
60
|
+
// directory. Keep that conversion explicit: a user-supplied trailing slash
|
|
61
|
+
// on an ACL command instead means a private folder.
|
|
62
|
+
return toSharedFolderGlob(rel);
|
|
60
63
|
}
|
|
61
64
|
/** Classify a share principal (@all | email | grp_*) — null when invalid. */
|
|
62
65
|
export function classifyPrincipal(principal) {
|