@indigoai-us/hq-cli 5.38.1 → 5.38.2
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 +27 -0
- package/dist/commands/onboard.js +8 -3
- 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/utils/vault-api.test.ts +88 -4
- package/src/utils/vault-api.ts +111 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.38.2]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`--company <slug>` no longer strands a user when two tenants share a slug
|
|
10
|
+
(DEV-1742 / feedback_a7e5abd9).** Under hq-pro's per-user-namespace model two
|
|
11
|
+
unrelated tenants may each legitimately hold the same company slug. The shared
|
|
12
|
+
company resolver resolved `--company` through the **global** `/entity/by-slug`
|
|
13
|
+
lookup, which `409`s on a collision and told the user to "disambiguate by
|
|
14
|
+
uid" — but passing that uid back to `--company` was fed into the same by-slug
|
|
15
|
+
path and `404`d, leaving no working escape (the dead-end behind the
|
|
16
|
+
liverecover duplicate). The resolver now resolves a slug **within the caller's
|
|
17
|
+
namespace first** via `/entity/check-slug/me` — within one caller's world a
|
|
18
|
+
slug is unique (enforced by hq-pro at create/accept/claim), so the caller's
|
|
19
|
+
own company resolves cleanly and a stranger's same-slug company is invisible.
|
|
20
|
+
Falls back to the global lookup for cross-company/admin resolutions; a residual
|
|
21
|
+
multi-match the caller doesn't belong to now yields an actionable
|
|
22
|
+
`--company <uid>` message; a `cmp_` uid resolves directly as a silent escape
|
|
23
|
+
hatch. One shared resolver, so this fixes `files`, `members`, `groups`,
|
|
24
|
+
`group-grants`, `meetings`, and `secrets` at once.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **`hq onboard create-company` guidance** now steers a would-be joiner toward
|
|
29
|
+
`hq onboard join`, so people stop minting a separate same-named company they're
|
|
30
|
+
alone in. No create-time behavior change.
|
|
31
|
+
|
|
5
32
|
## [5.38.0]
|
|
6
33
|
|
|
7
34
|
### 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
|
|
@@ -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({
|
|
@@ -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
|
|