@indigoai-us/hq-cli 5.38.1 → 5.39.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 +45 -0
- package/dist/commands/onboard.js +8 -3
- package/dist/commands/secrets.js +44 -2
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +94 -8
- package/package.json +1 -1
- package/src/commands/onboard.ts +12 -1
- package/src/commands/secrets.test.ts +65 -0
- package/src/commands/secrets.ts +52 -0
- package/src/utils/vault-api.test.ts +88 -4
- package/src/utils/vault-api.ts +111 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.39.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`hq secrets exists <name>` — HEAD existence probe (HQ-4H).** Checks whether a
|
|
10
|
+
secret exists without fetching or decrypting its value, so optional-credential
|
|
11
|
+
readers can HEAD-first instead of blind-`GET`ting:
|
|
12
|
+
`hq secrets exists FOO && hq secrets get FOO --reveal`. A missing secret no
|
|
13
|
+
longer fires a spurious server-side `Secret not found` Sentry warning (HQ-4H:
|
|
14
|
+
706 nameless events / 0 users) — a `HEAD` is by contract an expected-absence
|
|
15
|
+
probe, so a not-found is its normal answer and is not captured. Exit codes are
|
|
16
|
+
built for shell chaining: `0` present, `1` absent (the normal "no", not an
|
|
17
|
+
error), `2` real failure (auth/network/permission) so `&&` chains never
|
|
18
|
+
mistake an outage for "absent and proceed". `--quiet` suppresses the
|
|
19
|
+
present/absent line. Backed by the new hq-pro `HEAD
|
|
20
|
+
/secrets/{companyUid}/name/{proxy+}` route. Callers that genuinely *require* a
|
|
21
|
+
secret keep `GET`ting directly — their 404s still surface.
|
|
22
|
+
|
|
23
|
+
## [5.38.2]
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`--company <slug>` no longer strands a user when two tenants share a slug
|
|
28
|
+
(DEV-1742 / feedback_a7e5abd9).** Under hq-pro's per-user-namespace model two
|
|
29
|
+
unrelated tenants may each legitimately hold the same company slug. The shared
|
|
30
|
+
company resolver resolved `--company` through the **global** `/entity/by-slug`
|
|
31
|
+
lookup, which `409`s on a collision and told the user to "disambiguate by
|
|
32
|
+
uid" — but passing that uid back to `--company` was fed into the same by-slug
|
|
33
|
+
path and `404`d, leaving no working escape (the dead-end behind the
|
|
34
|
+
liverecover duplicate). The resolver now resolves a slug **within the caller's
|
|
35
|
+
namespace first** via `/entity/check-slug/me` — within one caller's world a
|
|
36
|
+
slug is unique (enforced by hq-pro at create/accept/claim), so the caller's
|
|
37
|
+
own company resolves cleanly and a stranger's same-slug company is invisible.
|
|
38
|
+
Falls back to the global lookup for cross-company/admin resolutions; a residual
|
|
39
|
+
multi-match the caller doesn't belong to now yields an actionable
|
|
40
|
+
`--company <uid>` message; a `cmp_` uid resolves directly as a silent escape
|
|
41
|
+
hatch. One shared resolver, so this fixes `files`, `members`, `groups`,
|
|
42
|
+
`group-grants`, `meetings`, and `secrets` at once.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **`hq onboard create-company` guidance** now steers a would-be joiner toward
|
|
47
|
+
`hq onboard join`, so people stop minting a separate same-named company they're
|
|
48
|
+
alone in. No create-time behavior change.
|
|
49
|
+
|
|
5
50
|
## [5.38.0]
|
|
6
51
|
|
|
7
52
|
### Fixed
|
package/dist/commands/onboard.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* browser-OAuth flow opens automatically.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
!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]="
|
|
22
|
+
!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]="99e9ce57-1051-59ce-8897-edcc83e83a16")}catch(e){}}();
|
|
23
23
|
import chalk from "chalk";
|
|
24
24
|
import { runOnboardCli, readCheckpoint } from "@indigoai-us/hq-onboarding";
|
|
25
25
|
import { DEFAULT_HQ_ROOT, DEFAULT_VAULT_API_URL, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
@@ -34,7 +34,9 @@ export function registerOnboardCommand(program) {
|
|
|
34
34
|
.description("Provision an HQ vault: sign in, create company, S3 bucket, STS, sync");
|
|
35
35
|
onboard
|
|
36
36
|
.command("create-company")
|
|
37
|
-
.description("Sign in and provision a brand
|
|
37
|
+
.description("Sign in and provision a brand NEW HQ vault for a company. To join a " +
|
|
38
|
+
"teammate's EXISTING company instead, use `hq onboard join` — creating " +
|
|
39
|
+
"a same-named company makes a separate one you'd be alone in.")
|
|
38
40
|
.requiredOption("--slug <slug>", "Company slug (used as bucket name suffix)")
|
|
39
41
|
.requiredOption("--name <name>", "Company display name")
|
|
40
42
|
.requiredOption("--email <email>", "Your email (must match Cognito sign-in)")
|
|
@@ -46,6 +48,9 @@ export function registerOnboardCommand(program) {
|
|
|
46
48
|
console.log(` Company: ${options.name} (${options.slug})`);
|
|
47
49
|
console.log(` Person: ${options.personName} <${options.email}>`);
|
|
48
50
|
console.log(` HQ root: ${options.hqRoot}\n`);
|
|
51
|
+
console.log(chalk.gray(" This creates a NEW company you own. Joining a teammate's existing\n" +
|
|
52
|
+
" company? Stop and run `hq onboard join --invite-token <token>` —\n" +
|
|
53
|
+
" creating a same-named company leaves you alone in a separate one.\n"));
|
|
49
54
|
const accessToken = await ensureCognitoToken();
|
|
50
55
|
const result = await runOnboardCli({
|
|
51
56
|
mode: "create-company",
|
|
@@ -174,4 +179,4 @@ export function registerOnboardCommand(program) {
|
|
|
174
179
|
});
|
|
175
180
|
}
|
|
176
181
|
//# sourceMappingURL=onboard.js.map
|
|
177
|
-
//# debugId=
|
|
182
|
+
//# debugId=99e9ce57-1051-59ce-8897-edcc83e83a16
|
package/dist/commands/secrets.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!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]="
|
|
2
|
+
!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]="7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import * as readline from "node:readline";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
@@ -227,6 +227,48 @@ export function registerSecretsCommand(program) {
|
|
|
227
227
|
process.exit(1);
|
|
228
228
|
}
|
|
229
229
|
});
|
|
230
|
+
// HQ-4H: existence probe via the HEAD route. Optional-credential readers
|
|
231
|
+
// should HEAD-first so a missing secret no longer fires a spurious GET-404
|
|
232
|
+
// Sentry warning server-side: `hq secrets exists FOO && hq secrets get FOO …`.
|
|
233
|
+
// A HEAD never decrypts or reveals the value.
|
|
234
|
+
//
|
|
235
|
+
// Exit codes are designed for shell chaining:
|
|
236
|
+
// 0 → secret exists (200)
|
|
237
|
+
// 1 → secret is absent (404) — the normal "no" answer, NOT an error
|
|
238
|
+
// 2 → a real failure (auth/network/permission) — distinct from absence so
|
|
239
|
+
// `&&` chains don't mistake an outage for "absent and proceed"
|
|
240
|
+
secrets
|
|
241
|
+
.command("exists <name>")
|
|
242
|
+
.description("Check whether a secret exists (HEAD; exit 0=present, 1=absent, 2=error)")
|
|
243
|
+
.option("--quiet", "Suppress the present/absent line (use the exit code only)")
|
|
244
|
+
.action(async (name, opts) => {
|
|
245
|
+
try {
|
|
246
|
+
const token = await ensureCognitoToken();
|
|
247
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
248
|
+
const res = await vaultApiFetch({
|
|
249
|
+
token,
|
|
250
|
+
method: "HEAD",
|
|
251
|
+
path: buildSecretNamePath(companyUid, name),
|
|
252
|
+
});
|
|
253
|
+
if (res.status === 200) {
|
|
254
|
+
if (!opts.quiet)
|
|
255
|
+
console.log(chalk.green(`exists: ${name}`));
|
|
256
|
+
process.exit(0);
|
|
257
|
+
}
|
|
258
|
+
if (res.status === 404) {
|
|
259
|
+
if (!opts.quiet)
|
|
260
|
+
console.log(chalk.dim(`absent: ${name}`));
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
// Any other status (401/403/5xx) is a real failure, not an absence.
|
|
264
|
+
console.error(chalk.red(`Failed to check secret '${name}': HTTP ${res.status} ${res.statusText}`));
|
|
265
|
+
process.exit(2);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
269
|
+
process.exit(2);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
230
272
|
secrets
|
|
231
273
|
.command("list")
|
|
232
274
|
.description("List all secrets for the company (including nested path-based names)")
|
|
@@ -712,4 +754,4 @@ export function registerSecretsCommand(program) {
|
|
|
712
754
|
});
|
|
713
755
|
}
|
|
714
756
|
//# sourceMappingURL=secrets.js.map
|
|
715
|
-
//# debugId=
|
|
757
|
+
//# debugId=7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae
|
|
@@ -17,6 +17,7 @@ export declare function vaultApiFetchPublic(opts: {
|
|
|
17
17
|
path: string;
|
|
18
18
|
query?: Record<string, string>;
|
|
19
19
|
}): Promise<Response>;
|
|
20
|
+
export declare function looksLikeCompanyUid(ref: string): boolean;
|
|
20
21
|
export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
|
|
21
22
|
export declare function resolveCallerPersonUid(token: string): Promise<string>;
|
|
22
23
|
export declare function getEntityUid(token: string, opts: {
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!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]="
|
|
2
|
+
!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]="85e24f6f-4027-5b00-a4a4-b9a140897d7e")}catch(e){}}();
|
|
3
3
|
import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
|
|
4
4
|
import { Sentry } from '../sentry.js';
|
|
5
5
|
export async function vaultApiFetch(opts) {
|
|
@@ -72,14 +72,98 @@ export async function vaultApiFetchPublic(opts) {
|
|
|
72
72
|
}
|
|
73
73
|
return response;
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* A `--company` value beginning with `cmp_` is a company UID (globally unique
|
|
77
|
+
* by ULID), not a slug. Recognised so the resolver can route it directly by
|
|
78
|
+
* uid — a silent escape hatch, NOT the path users are expected to type.
|
|
79
|
+
* Mirrors hq-cloud's `KNOWN_UID_PREFIXES` / `context.ts` dispatch so the two
|
|
80
|
+
* CLI codebases agree on the uid shape.
|
|
81
|
+
*/
|
|
82
|
+
const COMPANY_UID_PREFIX = 'cmp_';
|
|
83
|
+
export function looksLikeCompanyUid(ref) {
|
|
84
|
+
return ref.startsWith(COMPANY_UID_PREFIX);
|
|
85
|
+
}
|
|
86
|
+
async function resolveCompanyByUid(token, uid) {
|
|
76
87
|
const res = await vaultApiFetch({
|
|
77
88
|
token,
|
|
78
|
-
path: `/entity
|
|
89
|
+
path: `/entity/${encodeURIComponent(uid)}`,
|
|
79
90
|
});
|
|
80
91
|
if (!res.ok) {
|
|
81
|
-
const body = await res.json().catch(() => ({}));
|
|
82
|
-
throw new Error(`Failed to resolve company
|
|
92
|
+
const body = (await res.json().catch(() => ({})));
|
|
93
|
+
throw new Error(`Failed to resolve company '${uid}': ${body.error ?? res.statusText}`);
|
|
94
|
+
}
|
|
95
|
+
const data = (await res.json());
|
|
96
|
+
return data.entity.uid;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a company slug WITHIN THE CALLER'S NAMESPACE via the server's
|
|
100
|
+
* caller-scoped `/entity/check-slug/me` endpoint. Returns the caller's own
|
|
101
|
+
* company uid for that slug, or `null` when the caller owns / actively belongs
|
|
102
|
+
* to no company with that slug.
|
|
103
|
+
*
|
|
104
|
+
* This is the load-bearing fix for DEV-1742. Under hq-pro's per-user-namespace
|
|
105
|
+
* model two unrelated tenants may each hold "acme", so the legacy GLOBAL
|
|
106
|
+
* `/entity/by-slug` lookup 409s `SlugNotUniqueError` on a collision and strands
|
|
107
|
+
* the caller — exactly how the liverecover duplicate stayed unreachable. But
|
|
108
|
+
* within ONE caller's world a slug IS unique: hq-pro blocks a second same-slug
|
|
109
|
+
* company from entering a caller's namespace at every seam — create
|
|
110
|
+
* (`findSlugInPersonNamespace` in POST /entity), accept (POST /membership/accept
|
|
111
|
+
* → SlugInUserNamespaceError), and claim (claimByEmail skips the colliding
|
|
112
|
+
* invite). So "the caller's company called <slug>" is unambiguous, and the
|
|
113
|
+
* stranger's same-slug company is correctly invisible to resolution. This is
|
|
114
|
+
* the same primitive hq-cloud's `context.ts` already resolves slugs through.
|
|
115
|
+
*/
|
|
116
|
+
async function resolveSlugInCallerNamespace(token, slug) {
|
|
117
|
+
const res = await vaultApiFetch({
|
|
118
|
+
token,
|
|
119
|
+
path: '/entity/check-slug/me',
|
|
120
|
+
query: { type: 'company', slug },
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
// Namespace lookup unavailable (e.g. membership table not configured →
|
|
124
|
+
// 503, or the caller has no person entity). Signal "couldn't resolve here"
|
|
125
|
+
// and let the caller fall back to the global lookup. vaultApiFetch already
|
|
126
|
+
// recorded the non-2xx as a Sentry breadcrumb, so this is not a silent
|
|
127
|
+
// swallow.
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const data = (await res.json());
|
|
131
|
+
// `available: false` carries the caller's own company holding the slug.
|
|
132
|
+
return data.conflictingCompanyUid ?? null;
|
|
133
|
+
}
|
|
134
|
+
async function resolveCompanyUid(token, ref) {
|
|
135
|
+
// Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
|
|
136
|
+
if (looksLikeCompanyUid(ref)) {
|
|
137
|
+
return resolveCompanyByUid(token, ref);
|
|
138
|
+
}
|
|
139
|
+
// PRIMARY PATH — caller-scoped slug resolution. Resolves the slug to the
|
|
140
|
+
// caller's OWN company (unique within their namespace by the invariant
|
|
141
|
+
// above), making a stranger's same-slug company invisible.
|
|
142
|
+
const mine = await resolveSlugInCallerNamespace(token, ref);
|
|
143
|
+
if (mine) {
|
|
144
|
+
return mine;
|
|
145
|
+
}
|
|
146
|
+
// The caller owns / belongs to no company with this slug. Fall back to the
|
|
147
|
+
// GLOBAL by-slug lookup for the cross-company / admin resolutions that
|
|
148
|
+
// intentionally target a company the caller isn't a member of (e.g. a
|
|
149
|
+
// group-grant target). A single global match resolves cleanly.
|
|
150
|
+
const res = await vaultApiFetch({
|
|
151
|
+
token,
|
|
152
|
+
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
const body = (await res.json().catch(() => ({})));
|
|
156
|
+
// Residual true ambiguity: the slug matches multiple live companies and
|
|
157
|
+
// NONE is in the caller's namespace (server returns 409 SlugNotUniqueError
|
|
158
|
+
// with the colliding `uids`). The caller-scoped path can't pick one, so
|
|
159
|
+
// tell them exactly how — re-run with `--company <uid>` — and list the
|
|
160
|
+
// candidates, instead of echoing the generic server message.
|
|
161
|
+
if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
|
|
162
|
+
throw new Error(`Company slug '${ref}' matches ${body.uids.length} companies and none ` +
|
|
163
|
+
`is in your namespace. Re-run with --company <uid> to pick one:\n` +
|
|
164
|
+
body.uids.map((u) => ` --company ${u}`).join('\n'));
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`);
|
|
83
167
|
}
|
|
84
168
|
const data = (await res.json());
|
|
85
169
|
return data.entity.uid;
|
|
@@ -100,8 +184,10 @@ async function resolveCompanyFromMemberships(token) {
|
|
|
100
184
|
if (active.length === 1) {
|
|
101
185
|
return active[0].companyUid;
|
|
102
186
|
}
|
|
103
|
-
const uids = active.map((m) => m.companyUid)
|
|
104
|
-
throw new Error(`Multiple companies found
|
|
187
|
+
const uids = active.map((m) => m.companyUid);
|
|
188
|
+
throw new Error(`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
|
|
189
|
+
`pick one:\n` +
|
|
190
|
+
uids.map((u) => ` --company ${u}`).join('\n'));
|
|
105
191
|
}
|
|
106
192
|
export async function getCompanyUid(token, companySlug) {
|
|
107
193
|
if (companySlug) {
|
|
@@ -143,4 +229,4 @@ export async function getEntityUid(token, opts) {
|
|
|
143
229
|
return getCompanyUid(token, opts.companySlug);
|
|
144
230
|
}
|
|
145
231
|
//# sourceMappingURL=vault-api.js.map
|
|
146
|
-
//# debugId=
|
|
232
|
+
//# debugId=85e24f6f-4027-5b00-a4a4-b9a140897d7e
|
package/package.json
CHANGED
package/src/commands/onboard.ts
CHANGED
|
@@ -43,7 +43,11 @@ export function registerOnboardCommand(program: Command): void {
|
|
|
43
43
|
|
|
44
44
|
onboard
|
|
45
45
|
.command("create-company")
|
|
46
|
-
.description(
|
|
46
|
+
.description(
|
|
47
|
+
"Sign in and provision a brand NEW HQ vault for a company. To join a " +
|
|
48
|
+
"teammate's EXISTING company instead, use `hq onboard join` — creating " +
|
|
49
|
+
"a same-named company makes a separate one you'd be alone in.",
|
|
50
|
+
)
|
|
47
51
|
.requiredOption("--slug <slug>", "Company slug (used as bucket name suffix)")
|
|
48
52
|
.requiredOption("--name <name>", "Company display name")
|
|
49
53
|
.requiredOption("--email <email>", "Your email (must match Cognito sign-in)")
|
|
@@ -65,6 +69,13 @@ export function registerOnboardCommand(program: Command): void {
|
|
|
65
69
|
console.log(` Company: ${options.name} (${options.slug})`);
|
|
66
70
|
console.log(` Person: ${options.personName} <${options.email}>`);
|
|
67
71
|
console.log(` HQ root: ${options.hqRoot}\n`);
|
|
72
|
+
console.log(
|
|
73
|
+
chalk.gray(
|
|
74
|
+
" This creates a NEW company you own. Joining a teammate's existing\n" +
|
|
75
|
+
" company? Stop and run `hq onboard join --invite-token <token>` —\n" +
|
|
76
|
+
" creating a same-named company leaves you alone in a separate one.\n",
|
|
77
|
+
),
|
|
78
|
+
);
|
|
68
79
|
|
|
69
80
|
const accessToken = await ensureCognitoToken();
|
|
70
81
|
const result = await runOnboardCli({
|
|
@@ -62,6 +62,71 @@ function buildProgram(): Command {
|
|
|
62
62
|
return program;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// HQ-4H: `hq secrets exists` — HEAD existence probe with shell-chaining exit
|
|
66
|
+
// codes (0=present, 1=absent, 2=error). process.exit is spied so the command's
|
|
67
|
+
// terminal exit doesn't kill the runner; we assert the code it requested.
|
|
68
|
+
describe("secrets exists (HQ-4H HEAD probe)", () => {
|
|
69
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
// Throw so the command stops at its first process.exit (the real runtime
|
|
73
|
+
// terminates there). The action's own try/catch re-invokes exit(2) on the
|
|
74
|
+
// thrown sentinel, so we assert on the FIRST recorded exit code — the one
|
|
75
|
+
// the command actually intended — not the last.
|
|
76
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
77
|
+
throw new Error("__exit__");
|
|
78
|
+
}) as never);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
async function firstExitCode(name: string): Promise<number | undefined> {
|
|
82
|
+
const program = buildProgram();
|
|
83
|
+
try {
|
|
84
|
+
await program.parseAsync(["node", "hq", "secrets", "exists", name]);
|
|
85
|
+
} catch {
|
|
86
|
+
// sentinel(s) from the exit spy
|
|
87
|
+
}
|
|
88
|
+
return exitSpy.mock.calls[0]?.[0] as number | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
it("issues a HEAD and intends exit 0 when the secret exists (200)", async () => {
|
|
92
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
93
|
+
new Response(null, { status: 200 }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const code = await firstExitCode("MY_KEY");
|
|
97
|
+
|
|
98
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
99
|
+
token: "test-token",
|
|
100
|
+
method: "HEAD",
|
|
101
|
+
path: "/secrets/prs_alice/name/MY_KEY",
|
|
102
|
+
});
|
|
103
|
+
expect(code).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("intends exit 1 (absent, not an error) on 404", async () => {
|
|
107
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
108
|
+
new Response(
|
|
109
|
+
JSON.stringify({ error: "Secret not found", name: "MISSING" }),
|
|
110
|
+
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const code = await firstExitCode("MISSING");
|
|
115
|
+
|
|
116
|
+
expect(code).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("intends exit 2 (real failure, distinct from absence) on a 5xx", async () => {
|
|
120
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
121
|
+
new Response("boom", { status: 503 }),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const code = await firstExitCode("MY_KEY");
|
|
125
|
+
|
|
126
|
+
expect(code).toBe(2);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
65
130
|
describe("secrets generate-link", () => {
|
|
66
131
|
it("mints one-time submission links for personal secrets", async () => {
|
|
67
132
|
const program = buildProgram();
|
package/src/commands/secrets.ts
CHANGED
|
@@ -294,6 +294,58 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
294
294
|
}
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
// HQ-4H: existence probe via the HEAD route. Optional-credential readers
|
|
298
|
+
// should HEAD-first so a missing secret no longer fires a spurious GET-404
|
|
299
|
+
// Sentry warning server-side: `hq secrets exists FOO && hq secrets get FOO …`.
|
|
300
|
+
// A HEAD never decrypts or reveals the value.
|
|
301
|
+
//
|
|
302
|
+
// Exit codes are designed for shell chaining:
|
|
303
|
+
// 0 → secret exists (200)
|
|
304
|
+
// 1 → secret is absent (404) — the normal "no" answer, NOT an error
|
|
305
|
+
// 2 → a real failure (auth/network/permission) — distinct from absence so
|
|
306
|
+
// `&&` chains don't mistake an outage for "absent and proceed"
|
|
307
|
+
secrets
|
|
308
|
+
.command("exists <name>")
|
|
309
|
+
.description("Check whether a secret exists (HEAD; exit 0=present, 1=absent, 2=error)")
|
|
310
|
+
.option("--quiet", "Suppress the present/absent line (use the exit code only)")
|
|
311
|
+
.action(async (name: string, opts: { quiet?: boolean }) => {
|
|
312
|
+
try {
|
|
313
|
+
const token = await ensureCognitoToken();
|
|
314
|
+
const companyUid = await getEntityUid(
|
|
315
|
+
token,
|
|
316
|
+
scopeOpts(secrets.opts()),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const res = await vaultApiFetch({
|
|
320
|
+
token,
|
|
321
|
+
method: "HEAD",
|
|
322
|
+
path: buildSecretNamePath(companyUid, name),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (res.status === 200) {
|
|
326
|
+
if (!opts.quiet) console.log(chalk.green(`exists: ${name}`));
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
if (res.status === 404) {
|
|
330
|
+
if (!opts.quiet) console.log(chalk.dim(`absent: ${name}`));
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
// Any other status (401/403/5xx) is a real failure, not an absence.
|
|
334
|
+
console.error(
|
|
335
|
+
chalk.red(
|
|
336
|
+
`Failed to check secret '${name}': HTTP ${res.status} ${res.statusText}`,
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
process.exit(2);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(
|
|
342
|
+
chalk.red("Error:"),
|
|
343
|
+
err instanceof Error ? err.message : String(err),
|
|
344
|
+
);
|
|
345
|
+
process.exit(2);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
297
349
|
secrets
|
|
298
350
|
.command("list")
|
|
299
351
|
.description("List all secrets for the company (including nested path-based names)")
|
|
@@ -85,14 +85,98 @@ describe('getEntityUid', () => {
|
|
|
85
85
|
expect(url).toMatch(/\/entity\/by-type\/person/);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
it('
|
|
88
|
+
it('resolves a slug CALLER-SCOPED — to the caller\'s own company, ignoring a stranger\'s same-slug company (DEV-1742)', async () => {
|
|
89
|
+
// The slug resolves within the caller's namespace via check-slug/me, which
|
|
90
|
+
// returns the caller's OWN company. A stranger's same-slug company is
|
|
91
|
+
// invisible — no by-slug 409, no stranding.
|
|
89
92
|
fetchMock.mockResolvedValueOnce(
|
|
90
|
-
mockResponse(200, {
|
|
93
|
+
mockResponse(200, { available: false, conflictingCompanyUid: 'cmp_mine' }),
|
|
91
94
|
);
|
|
92
95
|
const uid = await getEntityUid('tok', { companySlug: 'acme' });
|
|
93
|
-
expect(uid).toBe('
|
|
96
|
+
expect(uid).toBe('cmp_mine');
|
|
94
97
|
const url = fetchMock.mock.calls[0][0] as string;
|
|
95
|
-
expect(url).toMatch(/\/entity\/
|
|
98
|
+
expect(url).toMatch(/\/entity\/check-slug\/me/);
|
|
99
|
+
// The caller-scoped hit is authoritative — never falls through to by-slug.
|
|
100
|
+
expect(fetchMock.mock.calls.length).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('falls back to the global by-slug lookup when the slug is not in the caller\'s namespace', async () => {
|
|
104
|
+
fetchMock
|
|
105
|
+
.mockResolvedValueOnce(mockResponse(200, { available: true }))
|
|
106
|
+
.mockResolvedValueOnce(mockResponse(200, { entity: { uid: 'cmp_other' } }));
|
|
107
|
+
const uid = await getEntityUid('tok', { companySlug: 'other-co' });
|
|
108
|
+
expect(uid).toBe('cmp_other');
|
|
109
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/entity\/check-slug\/me/);
|
|
110
|
+
expect(fetchMock.mock.calls[1][0]).toMatch(/\/entity\/by-slug\/company\/other-co/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('resolves a cmp_ uid DIRECTLY by uid, never via by-slug (silent escape hatch)', async () => {
|
|
114
|
+
fetchMock.mockResolvedValueOnce(
|
|
115
|
+
mockResponse(200, { entity: { uid: 'cmp_01ABC' } }),
|
|
116
|
+
);
|
|
117
|
+
const uid = await getEntityUid('tok', { companySlug: 'cmp_01ABC' });
|
|
118
|
+
expect(uid).toBe('cmp_01ABC');
|
|
119
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
120
|
+
expect(url).toMatch(/\/entity\/cmp_01ABC$/);
|
|
121
|
+
expect(url).not.toMatch(/by-slug/);
|
|
122
|
+
expect(url).not.toMatch(/check-slug/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('surfaces a clean error when a cmp_ uid does not resolve', async () => {
|
|
126
|
+
fetchMock.mockResolvedValueOnce(
|
|
127
|
+
mockResponse(404, { error: 'Entity not found', uid: 'cmp_nope' }),
|
|
128
|
+
);
|
|
129
|
+
await expect(
|
|
130
|
+
getEntityUid('tok', { companySlug: 'cmp_nope' }),
|
|
131
|
+
).rejects.toThrow(/Failed to resolve company 'cmp_nope'/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('falls through to by-slug when the namespace check is unavailable (503)', async () => {
|
|
135
|
+
fetchMock
|
|
136
|
+
.mockResolvedValueOnce(mockResponse(503, { error: 'Membership table not configured' }))
|
|
137
|
+
.mockResolvedValueOnce(mockResponse(200, { entity: { uid: 'cmp_legacy' } }));
|
|
138
|
+
const uid = await getEntityUid('tok', { companySlug: 'acme' });
|
|
139
|
+
expect(uid).toBe('cmp_legacy');
|
|
140
|
+
expect(fetchMock.mock.calls[1][0]).toMatch(/\/entity\/by-slug\/company\/acme/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('on residual global ambiguity (not in namespace) gives actionable --company <uid> guidance', async () => {
|
|
144
|
+
// Caller belongs to no "acme"; the slug matches multiple strangers'
|
|
145
|
+
// companies. The server 409s with the colliding uids and the CLI must tell
|
|
146
|
+
// the user exactly how to disambiguate.
|
|
147
|
+
fetchMock
|
|
148
|
+
.mockResolvedValueOnce(mockResponse(200, { available: true }))
|
|
149
|
+
.mockResolvedValueOnce(
|
|
150
|
+
mockResponse(409, {
|
|
151
|
+
error:
|
|
152
|
+
'Slug "acme" of type "company" matches 2 live entities — caller must disambiguate by uid or ownerUid',
|
|
153
|
+
type: 'company',
|
|
154
|
+
slug: 'acme',
|
|
155
|
+
uids: ['cmp_one', 'cmp_two'],
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
const err = await getEntityUid('tok', { companySlug: 'acme' }).catch(
|
|
159
|
+
(e: Error) => e,
|
|
160
|
+
);
|
|
161
|
+
expect(err).toBeInstanceOf(Error);
|
|
162
|
+
expect((err as Error).message).toMatch(/matches 2 companies/);
|
|
163
|
+
expect((err as Error).message).toMatch(/--company cmp_one/);
|
|
164
|
+
expect((err as Error).message).toMatch(/--company cmp_two/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('lists candidate companies when membership fallback is ambiguous', async () => {
|
|
168
|
+
fetchMock.mockResolvedValueOnce(
|
|
169
|
+
mockResponse(200, {
|
|
170
|
+
memberships: [
|
|
171
|
+
{ companyUid: 'cmp_a', role: 'member', status: 'active', membershipKey: 'k1' },
|
|
172
|
+
{ companyUid: 'cmp_b', role: 'owner', status: 'active', membershipKey: 'k2' },
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
const err = await getEntityUid('tok', {}).catch((e: Error) => e);
|
|
177
|
+
expect(err).toBeInstanceOf(Error);
|
|
178
|
+
expect((err as Error).message).toMatch(/--company cmp_a/);
|
|
179
|
+
expect((err as Error).message).toMatch(/--company cmp_b/);
|
|
96
180
|
});
|
|
97
181
|
|
|
98
182
|
it('falls back to membership lookup when neither personal nor slug is set', async () => {
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -91,15 +91,118 @@ interface MembershipEntry {
|
|
|
91
91
|
membershipKey: string;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
/**
|
|
95
|
+
* A `--company` value beginning with `cmp_` is a company UID (globally unique
|
|
96
|
+
* by ULID), not a slug. Recognised so the resolver can route it directly by
|
|
97
|
+
* uid — a silent escape hatch, NOT the path users are expected to type.
|
|
98
|
+
* Mirrors hq-cloud's `KNOWN_UID_PREFIXES` / `context.ts` dispatch so the two
|
|
99
|
+
* CLI codebases agree on the uid shape.
|
|
100
|
+
*/
|
|
101
|
+
const COMPANY_UID_PREFIX = 'cmp_';
|
|
102
|
+
|
|
103
|
+
export function looksLikeCompanyUid(ref: string): boolean {
|
|
104
|
+
return ref.startsWith(COMPANY_UID_PREFIX);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function resolveCompanyByUid(token: string, uid: string): Promise<string> {
|
|
95
108
|
const res = await vaultApiFetch({
|
|
96
109
|
token,
|
|
97
|
-
path: `/entity
|
|
110
|
+
path: `/entity/${encodeURIComponent(uid)}`,
|
|
98
111
|
});
|
|
99
112
|
if (!res.ok) {
|
|
100
|
-
const body = await res.json().catch(() => ({}));
|
|
113
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Failed to resolve company '${uid}': ${body.error ?? res.statusText}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const data = (await res.json()) as { entity: { uid: string } };
|
|
119
|
+
return data.entity.uid;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a company slug WITHIN THE CALLER'S NAMESPACE via the server's
|
|
124
|
+
* caller-scoped `/entity/check-slug/me` endpoint. Returns the caller's own
|
|
125
|
+
* company uid for that slug, or `null` when the caller owns / actively belongs
|
|
126
|
+
* to no company with that slug.
|
|
127
|
+
*
|
|
128
|
+
* This is the load-bearing fix for DEV-1742. Under hq-pro's per-user-namespace
|
|
129
|
+
* model two unrelated tenants may each hold "acme", so the legacy GLOBAL
|
|
130
|
+
* `/entity/by-slug` lookup 409s `SlugNotUniqueError` on a collision and strands
|
|
131
|
+
* the caller — exactly how the liverecover duplicate stayed unreachable. But
|
|
132
|
+
* within ONE caller's world a slug IS unique: hq-pro blocks a second same-slug
|
|
133
|
+
* company from entering a caller's namespace at every seam — create
|
|
134
|
+
* (`findSlugInPersonNamespace` in POST /entity), accept (POST /membership/accept
|
|
135
|
+
* → SlugInUserNamespaceError), and claim (claimByEmail skips the colliding
|
|
136
|
+
* invite). So "the caller's company called <slug>" is unambiguous, and the
|
|
137
|
+
* stranger's same-slug company is correctly invisible to resolution. This is
|
|
138
|
+
* the same primitive hq-cloud's `context.ts` already resolves slugs through.
|
|
139
|
+
*/
|
|
140
|
+
async function resolveSlugInCallerNamespace(
|
|
141
|
+
token: string,
|
|
142
|
+
slug: string,
|
|
143
|
+
): Promise<string | null> {
|
|
144
|
+
const res = await vaultApiFetch({
|
|
145
|
+
token,
|
|
146
|
+
path: '/entity/check-slug/me',
|
|
147
|
+
query: { type: 'company', slug },
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
// Namespace lookup unavailable (e.g. membership table not configured →
|
|
151
|
+
// 503, or the caller has no person entity). Signal "couldn't resolve here"
|
|
152
|
+
// and let the caller fall back to the global lookup. vaultApiFetch already
|
|
153
|
+
// recorded the non-2xx as a Sentry breadcrumb, so this is not a silent
|
|
154
|
+
// swallow.
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const data = (await res.json()) as {
|
|
158
|
+
available?: boolean;
|
|
159
|
+
conflictingCompanyUid?: string;
|
|
160
|
+
};
|
|
161
|
+
// `available: false` carries the caller's own company holding the slug.
|
|
162
|
+
return data.conflictingCompanyUid ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function resolveCompanyUid(token: string, ref: string): Promise<string> {
|
|
166
|
+
// Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
|
|
167
|
+
if (looksLikeCompanyUid(ref)) {
|
|
168
|
+
return resolveCompanyByUid(token, ref);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// PRIMARY PATH — caller-scoped slug resolution. Resolves the slug to the
|
|
172
|
+
// caller's OWN company (unique within their namespace by the invariant
|
|
173
|
+
// above), making a stranger's same-slug company invisible.
|
|
174
|
+
const mine = await resolveSlugInCallerNamespace(token, ref);
|
|
175
|
+
if (mine) {
|
|
176
|
+
return mine;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The caller owns / belongs to no company with this slug. Fall back to the
|
|
180
|
+
// GLOBAL by-slug lookup for the cross-company / admin resolutions that
|
|
181
|
+
// intentionally target a company the caller isn't a member of (e.g. a
|
|
182
|
+
// group-grant target). A single global match resolves cleanly.
|
|
183
|
+
const res = await vaultApiFetch({
|
|
184
|
+
token,
|
|
185
|
+
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
186
|
+
});
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
189
|
+
error?: string;
|
|
190
|
+
uids?: string[];
|
|
191
|
+
};
|
|
192
|
+
// Residual true ambiguity: the slug matches multiple live companies and
|
|
193
|
+
// NONE is in the caller's namespace (server returns 409 SlugNotUniqueError
|
|
194
|
+
// with the colliding `uids`). The caller-scoped path can't pick one, so
|
|
195
|
+
// tell them exactly how — re-run with `--company <uid>` — and list the
|
|
196
|
+
// candidates, instead of echoing the generic server message.
|
|
197
|
+
if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Company slug '${ref}' matches ${body.uids.length} companies and none ` +
|
|
200
|
+
`is in your namespace. Re-run with --company <uid> to pick one:\n` +
|
|
201
|
+
body.uids.map((u) => ` --company ${u}`).join('\n'),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
101
204
|
throw new Error(
|
|
102
|
-
`Failed to resolve company slug '${
|
|
205
|
+
`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`,
|
|
103
206
|
);
|
|
104
207
|
}
|
|
105
208
|
const data = (await res.json()) as { entity: { uid: string } };
|
|
@@ -122,9 +225,11 @@ async function resolveCompanyFromMemberships(token: string): Promise<string> {
|
|
|
122
225
|
if (active.length === 1) {
|
|
123
226
|
return active[0].companyUid;
|
|
124
227
|
}
|
|
125
|
-
const uids = active.map((m) => m.companyUid)
|
|
228
|
+
const uids = active.map((m) => m.companyUid);
|
|
126
229
|
throw new Error(
|
|
127
|
-
`Multiple companies found
|
|
230
|
+
`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
|
|
231
|
+
`pick one:\n` +
|
|
232
|
+
uids.map((u) => ` --company ${u}`).join('\n'),
|
|
128
233
|
);
|
|
129
234
|
}
|
|
130
235
|
|