@indigoai-us/hq-cloud 5.18.1 → 5.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/invite.js +4 -1
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/invite.test.js +3 -0
- package/dist/cli/invite.test.js.map +1 -1
- package/dist/cli/promote.js +3 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/share.test.js +12 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.test.js +12 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +107 -18
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +63 -14
- package/dist/context.test.js.map +1 -1
- package/dist/vault-client.d.ts +24 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +29 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/invite.test.ts +3 -0
- package/src/cli/invite.ts +4 -1
- package/src/cli/promote.ts +3 -0
- package/src/cli/share.test.ts +12 -1
- package/src/cli/sync.test.ts +12 -0
- package/src/context.test.ts +73 -14
- package/src/context.ts +116 -20
- package/src/vault-client.ts +37 -0
package/src/cli/sync.test.ts
CHANGED
|
@@ -62,6 +62,18 @@ const mockVendResponse = {
|
|
|
62
62
|
function setupFetchMock() {
|
|
63
63
|
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
64
64
|
const urlStr = String(url);
|
|
65
|
+
// New per-user-namespace slug resolver (hq-pro PR 67). Returns the
|
|
66
|
+
// mockEntity's uid as the in-namespace match; the caller then
|
|
67
|
+
// re-fetches it via `/entity/{uid}`, which is matched by the
|
|
68
|
+
// `/entity/cmp_/` branch below.
|
|
69
|
+
if (urlStr.includes("/entity/check-slug/me")) {
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
status: 200,
|
|
73
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
74
|
+
text: async () => "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
65
77
|
if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
|
|
66
78
|
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
67
79
|
}
|
package/src/context.test.ts
CHANGED
|
@@ -47,6 +47,31 @@ function setupFetchMock(overrides?: {
|
|
|
47
47
|
fetchMock.mockImplementation(async (url: string) => {
|
|
48
48
|
const urlStr = String(url);
|
|
49
49
|
|
|
50
|
+
// New per-user-namespace slug resolver (hq-pro PR 67). Maps slug
|
|
51
|
+
// lookups to `{available: false, conflictingCompanyUid}` so the
|
|
52
|
+
// caller follows up with `/entity/{uid}`, which lands in the
|
|
53
|
+
// `/entity/cmp_` branch below — that's where `entityBody` and
|
|
54
|
+
// `entityStatus` overrides apply. The check-slug branch only
|
|
55
|
+
// honors `entityStatus` (so tests can simulate a 404/500 on the
|
|
56
|
+
// namespace lookup itself); its response shape stays fixed.
|
|
57
|
+
if (urlStr.includes("/entity/check-slug/me")) {
|
|
58
|
+
return {
|
|
59
|
+
ok: (overrides?.entityStatus ?? 200) < 400,
|
|
60
|
+
status: overrides?.entityStatus ?? 200,
|
|
61
|
+
json: async () => ({
|
|
62
|
+
available: false,
|
|
63
|
+
conflictingCompanyUid: mockEntity.uid,
|
|
64
|
+
}),
|
|
65
|
+
text: async () =>
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
available: false,
|
|
68
|
+
conflictingCompanyUid: mockEntity.uid,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Kept for any tests that still mock the legacy global endpoint
|
|
74
|
+
// directly (none should, post-PR 67 — but harmless if invoked).
|
|
50
75
|
if (urlStr.includes("/entity/by-slug/")) {
|
|
51
76
|
return {
|
|
52
77
|
ok: (overrides?.entityStatus ?? 200) < 400,
|
|
@@ -97,10 +122,16 @@ describe("resolveEntityContext", () => {
|
|
|
97
122
|
expect(ctx.credentials.accessKeyId).toBe("ASIA_TEST_KEY");
|
|
98
123
|
expect(ctx.region).toBe("us-east-1");
|
|
99
124
|
|
|
100
|
-
// Verify entity lookup used
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
expect(String(fetchMock.mock.calls[
|
|
125
|
+
// Verify entity lookup used the new per-user-namespace endpoint
|
|
126
|
+
// (PR 67) + a follow-up `/entity/{uid}` materialization + STS.
|
|
127
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
128
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain(
|
|
129
|
+
"/entity/check-slug/me?type=company&slug=acme",
|
|
130
|
+
);
|
|
131
|
+
expect(String(fetchMock.mock.calls[1][0])).toContain(
|
|
132
|
+
`/entity/${mockEntity.uid}`,
|
|
133
|
+
);
|
|
134
|
+
expect(String(fetchMock.mock.calls[2][0])).toContain("/sts/vend");
|
|
104
135
|
});
|
|
105
136
|
|
|
106
137
|
it("resolves context by UID directly", async () => {
|
|
@@ -120,7 +151,9 @@ describe("resolveEntityContext", () => {
|
|
|
120
151
|
const ctx2 = await resolveEntityContext("acme", mockConfig);
|
|
121
152
|
|
|
122
153
|
expect(ctx1).toBe(ctx2); // Same reference
|
|
123
|
-
|
|
154
|
+
// 3 fetches per resolve under the new model: check-slug + entity.get + sts/vend.
|
|
155
|
+
// 1 resolve here (second call hits cache, no new fetches).
|
|
156
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
124
157
|
});
|
|
125
158
|
|
|
126
159
|
it("auto-refreshes when credentials expire soon", async () => {
|
|
@@ -137,7 +170,8 @@ describe("resolveEntityContext", () => {
|
|
|
137
170
|
// Second call should refresh because <2 min remaining
|
|
138
171
|
const ctx2 = await resolveEntityContext("acme", mockConfig);
|
|
139
172
|
expect(ctx2).not.toBe(ctx1);
|
|
140
|
-
|
|
173
|
+
// 2 resolves × 3 fetches each = 6 under the new model.
|
|
174
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
141
175
|
});
|
|
142
176
|
|
|
143
177
|
it("throws when entity has no bucket", async () => {
|
|
@@ -153,8 +187,11 @@ describe("resolveEntityContext", () => {
|
|
|
153
187
|
it("throws on entity lookup failure", async () => {
|
|
154
188
|
setupFetchMock({ entityStatus: 404 });
|
|
155
189
|
|
|
190
|
+
// The namespace lookup fails first under the new model — error
|
|
191
|
+
// message now reflects "Failed to check slug" before the
|
|
192
|
+
// entity.get(uid) step is reached.
|
|
156
193
|
await expect(resolveEntityContext("nonexistent", mockConfig)).rejects.toThrow(
|
|
157
|
-
/Failed to
|
|
194
|
+
/Failed to check slug/,
|
|
158
195
|
);
|
|
159
196
|
});
|
|
160
197
|
|
|
@@ -201,12 +238,20 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
201
238
|
expect(vendCalls[0]).toContain("/sts/vend-self");
|
|
202
239
|
});
|
|
203
240
|
|
|
204
|
-
it("foo_bar slug: entity resolved via /entity/
|
|
241
|
+
it("foo_bar slug: entity resolved via /entity/check-slug/me + /entity/<uid> and credentials via /sts/vend", async () => {
|
|
205
242
|
const calls: string[] = [];
|
|
206
243
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
207
244
|
const u = String(url);
|
|
208
245
|
calls.push(u);
|
|
209
|
-
if (u.includes("/entity/
|
|
246
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
status: 200,
|
|
250
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
251
|
+
text: async () => "",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (u.includes(`/entity/${mockEntity.uid}`)) {
|
|
210
255
|
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
211
256
|
}
|
|
212
257
|
if (u.includes("/sts/vend")) {
|
|
@@ -217,19 +262,29 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
217
262
|
|
|
218
263
|
await resolveEntityContext("foo_bar", mockConfig);
|
|
219
264
|
|
|
220
|
-
expect(
|
|
265
|
+
expect(
|
|
266
|
+
calls.some((u) => u.includes("/entity/check-slug/me?type=company&slug=foo_bar")),
|
|
267
|
+
).toBe(true);
|
|
221
268
|
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
222
269
|
expect(vendCalls).toHaveLength(1);
|
|
223
270
|
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
224
271
|
expect(vendCalls[0]).toContain("/sts/vend");
|
|
225
272
|
});
|
|
226
273
|
|
|
227
|
-
it("team_alpha slug: entity resolved via /entity/
|
|
274
|
+
it("team_alpha slug: entity resolved via /entity/check-slug/me + /entity/<uid> and credentials via /sts/vend", async () => {
|
|
228
275
|
const calls: string[] = [];
|
|
229
276
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
230
277
|
const u = String(url);
|
|
231
278
|
calls.push(u);
|
|
232
|
-
if (u.includes("/entity/
|
|
279
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
status: 200,
|
|
283
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
284
|
+
text: async () => "",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (u.includes(`/entity/${mockEntity.uid}`)) {
|
|
233
288
|
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
234
289
|
}
|
|
235
290
|
if (u.includes("/sts/vend")) {
|
|
@@ -240,7 +295,9 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
240
295
|
|
|
241
296
|
await resolveEntityContext("team_alpha", mockConfig);
|
|
242
297
|
|
|
243
|
-
expect(
|
|
298
|
+
expect(
|
|
299
|
+
calls.some((u) => u.includes("/entity/check-slug/me?type=company&slug=team_alpha")),
|
|
300
|
+
).toBe(true);
|
|
244
301
|
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
245
302
|
expect(vendCalls).toHaveLength(1);
|
|
246
303
|
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
@@ -283,7 +340,9 @@ describe("refreshEntityContext", () => {
|
|
|
283
340
|
const ctx2 = await refreshEntityContext("acme", mockConfig);
|
|
284
341
|
|
|
285
342
|
expect(ctx2).not.toBe(ctx1);
|
|
286
|
-
|
|
343
|
+
// 2 resolves × 3 fetches each = 6 under the new model
|
|
344
|
+
// (check-slug + entity.get + sts/vend each).
|
|
345
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
287
346
|
});
|
|
288
347
|
});
|
|
289
348
|
|
package/src/context.ts
CHANGED
|
@@ -14,9 +14,56 @@ const REFRESH_THRESHOLD_MS = 2 * 60 * 1000;
|
|
|
14
14
|
/** STS session duration requested from vault-service (15 minutes). */
|
|
15
15
|
const DEFAULT_SESSION_DURATION_SECONDS = 900;
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Cached contexts.
|
|
19
|
+
*
|
|
20
|
+
* Two keying schemes share the map:
|
|
21
|
+
* - UID keys: bare `cmp_xxx` / `prs_xxx`. Globally unique by ULID, so
|
|
22
|
+
* the cached context is safe to return to any caller asking by uid.
|
|
23
|
+
* - Slug keys: `slug:${callerSub}#${slug}`. Under the per-user-namespace
|
|
24
|
+
* model on hq-pro (PR 67, live 2026-05-15), the same slug can
|
|
25
|
+
* legitimately resolve to different company UIDs for different
|
|
26
|
+
* callers. Keying slug entries by `(callerSub, slug)` prevents one
|
|
27
|
+
* caller's resolution from being served to another caller against
|
|
28
|
+
* the wrong tenant. Codex flagged this as a P1 on PR 67's hq-cloud
|
|
29
|
+
* follow-up.
|
|
30
|
+
*/
|
|
18
31
|
const contextCache = new Map<string, EntityContext>();
|
|
19
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Extract the Cognito sub claim from a JWT without verifying the
|
|
35
|
+
* signature. We don't need verification here — the token already
|
|
36
|
+
* authorized the upstream API call; we're only using `sub` as a
|
|
37
|
+
* per-caller cache discriminator. If the token is malformed or
|
|
38
|
+
* missing a sub, fall back to a hash of the whole token (slightly
|
|
39
|
+
* larger key space, same correctness — different tokens still
|
|
40
|
+
* get distinct cache entries).
|
|
41
|
+
*/
|
|
42
|
+
function callerKeyFromAccessToken(accessToken: string): string {
|
|
43
|
+
const parts = accessToken.split(".");
|
|
44
|
+
if (parts.length === 3) {
|
|
45
|
+
try {
|
|
46
|
+
// base64url decode (Node-compatible, no padding required)
|
|
47
|
+
const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
48
|
+
const payload = Buffer.from(padded, "base64").toString("utf8");
|
|
49
|
+
const claims = JSON.parse(payload) as { sub?: unknown };
|
|
50
|
+
if (typeof claims.sub === "string" && claims.sub.length > 0) {
|
|
51
|
+
return claims.sub;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// fall through to token-hash fallback
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Stable fallback — same token → same key, different tokens → different keys.
|
|
58
|
+
// (Avoids importing crypto for a sha; the raw token suffix is sufficient as
|
|
59
|
+
// a cache discriminator and isn't exposed outside this process.)
|
|
60
|
+
return `tok:${accessToken.slice(-32)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function slugCacheKey(callerKey: string, slug: string): string {
|
|
64
|
+
return `slug:${callerKey}#${slug}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
20
67
|
/**
|
|
21
68
|
* Closed-set of recognised entity-UID prefixes. Adding a new entity type
|
|
22
69
|
* means appending one entry here AND extending the dispatch in
|
|
@@ -35,20 +82,34 @@ export async function resolveEntityContext(
|
|
|
35
82
|
companyUidOrSlug: string,
|
|
36
83
|
config: VaultServiceConfig,
|
|
37
84
|
): Promise<EntityContext> {
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return cached;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Step 1: Resolve entity — if it looks like a known UID prefix, fetch directly;
|
|
45
|
-
// otherwise look up by slug. Explicit enumeration avoids over-matching slugs
|
|
46
|
-
// like foo_bar or team_alpha that happen to look like UIDs.
|
|
85
|
+
// Step 0: Determine whether the input is a UID (globally unique) or a
|
|
86
|
+
// slug (per-caller). The cache key differs accordingly — see the
|
|
87
|
+
// `contextCache` jsdoc.
|
|
47
88
|
const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
|
|
48
89
|
companyUidOrSlug.startsWith(p),
|
|
49
90
|
);
|
|
50
91
|
const looksLikePerson = companyUidOrSlug.startsWith("prs_");
|
|
51
92
|
|
|
93
|
+
// For slug lookups, scope the cache key by the caller. Resolving the
|
|
94
|
+
// access token here (once) doubles as a way to extract the sub for
|
|
95
|
+
// the key — same hop the downstream fetch makes anyway.
|
|
96
|
+
let cacheKey: string;
|
|
97
|
+
let callerKey: string | null = null;
|
|
98
|
+
if (looksLikeUid) {
|
|
99
|
+
cacheKey = companyUidOrSlug;
|
|
100
|
+
} else {
|
|
101
|
+
const token = await resolveAuthToken(config);
|
|
102
|
+
callerKey = callerKeyFromAccessToken(token);
|
|
103
|
+
cacheKey = slugCacheKey(callerKey, companyUidOrSlug);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check cache — return if credentials still fresh.
|
|
107
|
+
const cached = contextCache.get(cacheKey);
|
|
108
|
+
if (cached && !isExpiringSoon(cached.expiresAt)) {
|
|
109
|
+
return cached;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Step 1: Resolve entity — direct fetch by UID or namespace lookup by slug.
|
|
52
113
|
const entity = looksLikeUid
|
|
53
114
|
? await fetchEntity(companyUidOrSlug, config)
|
|
54
115
|
: await fetchEntityBySlug("company", companyUidOrSlug, config);
|
|
@@ -81,9 +142,13 @@ export async function resolveEntityContext(
|
|
|
81
142
|
expiresAt: vendResult.expiresAt,
|
|
82
143
|
};
|
|
83
144
|
|
|
84
|
-
// Cache by
|
|
145
|
+
// Cache by UID (globally unique) and — if the caller asked by slug —
|
|
146
|
+
// by `(callerSub, slug)` so the same slug can resolve to different
|
|
147
|
+
// entities per caller without cross-caller poisoning.
|
|
85
148
|
contextCache.set(entity.uid, ctx);
|
|
86
|
-
|
|
149
|
+
if (callerKey) {
|
|
150
|
+
contextCache.set(slugCacheKey(callerKey, entity.slug), ctx);
|
|
151
|
+
}
|
|
87
152
|
|
|
88
153
|
return ctx;
|
|
89
154
|
}
|
|
@@ -105,8 +170,18 @@ export async function refreshEntityContext(
|
|
|
105
170
|
companyUidOrSlug: string,
|
|
106
171
|
config: VaultServiceConfig,
|
|
107
172
|
): Promise<EntityContext> {
|
|
108
|
-
// Evict
|
|
109
|
-
|
|
173
|
+
// Evict the entry under whichever key shape applies. UID inputs key
|
|
174
|
+
// the cache by the bare uid; slug inputs key by `(callerSub, slug)`.
|
|
175
|
+
const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
|
|
176
|
+
companyUidOrSlug.startsWith(p),
|
|
177
|
+
);
|
|
178
|
+
if (looksLikeUid) {
|
|
179
|
+
contextCache.delete(companyUidOrSlug);
|
|
180
|
+
} else {
|
|
181
|
+
const token = await resolveAuthToken(config);
|
|
182
|
+
const callerKey = callerKeyFromAccessToken(token);
|
|
183
|
+
contextCache.delete(slugCacheKey(callerKey, companyUidOrSlug));
|
|
184
|
+
}
|
|
110
185
|
return resolveEntityContext(companyUidOrSlug, config);
|
|
111
186
|
}
|
|
112
187
|
|
|
@@ -171,17 +246,38 @@ async function fetchEntityBySlug(
|
|
|
171
246
|
slug: string,
|
|
172
247
|
config: VaultServiceConfig,
|
|
173
248
|
): Promise<EntityResponse> {
|
|
174
|
-
|
|
249
|
+
// Resolve the slug inside the CALLER's namespace via the new
|
|
250
|
+
// `/entity/check-slug/me` endpoint added in hq-pro PR 67 (live
|
|
251
|
+
// in prod 2026-05-15). Under the per-user-namespace model the
|
|
252
|
+
// legacy `/entity/by-slug/{type}/{slug}` either over-matches
|
|
253
|
+
// (returns another tenant's entity when more than one user holds
|
|
254
|
+
// the slug) or 409s with SlugNotUniqueError — both wrong for the
|
|
255
|
+
// sync runner's "I want MY company" intent.
|
|
256
|
+
const checkUrl = `${config.apiUrl}/entity/check-slug/me?type=${encodeURIComponent(type)}&slug=${encodeURIComponent(slug)}`;
|
|
257
|
+
const checkRes = await fetch(checkUrl, {
|
|
175
258
|
headers: { Authorization: `Bearer ${await resolveAuthToken(config)}` },
|
|
176
259
|
});
|
|
177
|
-
if (!
|
|
178
|
-
const body = await
|
|
260
|
+
if (!checkRes.ok) {
|
|
261
|
+
const body = await checkRes.text();
|
|
179
262
|
throw new Error(
|
|
180
|
-
`Failed to
|
|
263
|
+
`Failed to check slug ${type}/${slug}: ${checkRes.status} ${body}`,
|
|
181
264
|
);
|
|
182
265
|
}
|
|
183
|
-
const
|
|
184
|
-
|
|
266
|
+
const check = (await checkRes.json()) as {
|
|
267
|
+
available: boolean;
|
|
268
|
+
conflictingCompanyUid?: string;
|
|
269
|
+
};
|
|
270
|
+
if (check.available || !check.conflictingCompanyUid) {
|
|
271
|
+
// The slug isn't in the caller's namespace. From the sync
|
|
272
|
+
// runner's perspective this is a genuine "not found" — the
|
|
273
|
+
// caller doesn't have a `${slug}` to sync, even if some other
|
|
274
|
+
// user does.
|
|
275
|
+
throw new Error(
|
|
276
|
+
`No entity in caller's namespace matches ${type}/${slug}. ` +
|
|
277
|
+
`If you expected to find one, verify you're signed in to the right HQ identity.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return fetchEntity(check.conflictingCompanyUid, config);
|
|
185
281
|
}
|
|
186
282
|
|
|
187
283
|
async function postVend(
|
package/src/vault-client.ts
CHANGED
|
@@ -332,6 +332,17 @@ export class VaultClient {
|
|
|
332
332
|
return data.entity;
|
|
333
333
|
},
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Legacy global slug lookup. Under the per-user-namespace model on
|
|
337
|
+
* hq-pro (PR indigoai-us/hq-pro#67, live in prod 2026-05-15) the
|
|
338
|
+
* server-side handler now uses `requireUnique: true` — this method
|
|
339
|
+
* returns a single entity when only one tenant holds the slug, 404s
|
|
340
|
+
* when nobody does, or 409s with `SlugNotUniqueError` and a list of
|
|
341
|
+
* colliding `uids` when more than one tenant holds it. Most CLI
|
|
342
|
+
* call sites have moved to `findInMyNamespace` (which respects the
|
|
343
|
+
* caller's effective namespace); only flows that genuinely want a
|
|
344
|
+
* global lookup (admin tooling) should still use this method.
|
|
345
|
+
*/
|
|
335
346
|
findBySlug: async (type: string, slug: string): Promise<EntityInfo> => {
|
|
336
347
|
const data = await this.get<{ entity: EntityInfo }>(
|
|
337
348
|
`/entity/by-slug/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`,
|
|
@@ -339,6 +350,32 @@ export class VaultClient {
|
|
|
339
350
|
return data.entity;
|
|
340
351
|
},
|
|
341
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Resolve an entity by slug within the CALLER's namespace
|
|
355
|
+
* (owned ∪ active-member-of, soft-deleted excluded). Hits the new
|
|
356
|
+
* `GET /entity/check-slug/me?type=&slug=` endpoint added in PR 67.
|
|
357
|
+
*
|
|
358
|
+
* Returns the full entity when present in the caller's namespace,
|
|
359
|
+
* or `null` when the slug isn't theirs — even if some OTHER user
|
|
360
|
+
* happens to own a company with the same slug. This is what every
|
|
361
|
+
* "find my-company by slug" flow wants under the per-user model;
|
|
362
|
+
* `findBySlug`'s global semantic would over-match (return a
|
|
363
|
+
* stranger's entity) or 409 (multi-tenant slug) in those cases.
|
|
364
|
+
*/
|
|
365
|
+
findInMyNamespace: async (
|
|
366
|
+
type: string,
|
|
367
|
+
slug: string,
|
|
368
|
+
): Promise<EntityInfo | null> => {
|
|
369
|
+
const check = await this.get<{
|
|
370
|
+
available: boolean;
|
|
371
|
+
conflictingCompanyUid?: string;
|
|
372
|
+
}>(
|
|
373
|
+
`/entity/check-slug/me?type=${encodeURIComponent(type)}&slug=${encodeURIComponent(slug)}`,
|
|
374
|
+
);
|
|
375
|
+
if (check.available || !check.conflictingCompanyUid) return null;
|
|
376
|
+
return this.entity.get(check.conflictingCompanyUid);
|
|
377
|
+
},
|
|
378
|
+
|
|
342
379
|
create: async (input: CreateEntityInput): Promise<EntityInfo> => {
|
|
343
380
|
const data = await this.post<CreateEntityResult>("/entity", input);
|
|
344
381
|
return data.entity;
|