@indigoai-us/hq-cloud 5.18.1 → 5.19.1

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.
Files changed (46) hide show
  1. package/dist/cli/invite.js +4 -1
  2. package/dist/cli/invite.js.map +1 -1
  3. package/dist/cli/invite.test.js +3 -0
  4. package/dist/cli/invite.test.js.map +1 -1
  5. package/dist/cli/promote.js +3 -0
  6. package/dist/cli/promote.js.map +1 -1
  7. package/dist/cli/share.test.js +12 -1
  8. package/dist/cli/share.test.js.map +1 -1
  9. package/dist/cli/sync.test.js +12 -0
  10. package/dist/cli/sync.test.js.map +1 -1
  11. package/dist/client-info.d.ts +44 -0
  12. package/dist/client-info.d.ts.map +1 -0
  13. package/dist/client-info.js +112 -0
  14. package/dist/client-info.js.map +1 -0
  15. package/dist/client-info.test.d.ts +11 -0
  16. package/dist/client-info.test.d.ts.map +1 -0
  17. package/dist/client-info.test.js +168 -0
  18. package/dist/client-info.test.js.map +1 -0
  19. package/dist/context.d.ts.map +1 -1
  20. package/dist/context.js +117 -20
  21. package/dist/context.js.map +1 -1
  22. package/dist/context.test.js +63 -14
  23. package/dist/context.test.js.map +1 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/types.d.ts +22 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/vault-client.d.ts +25 -0
  31. package/dist/vault-client.d.ts.map +1 -1
  32. package/dist/vault-client.js +33 -0
  33. package/dist/vault-client.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/invite.test.ts +3 -0
  36. package/src/cli/invite.ts +4 -1
  37. package/src/cli/promote.ts +3 -0
  38. package/src/cli/share.test.ts +12 -1
  39. package/src/cli/sync.test.ts +12 -0
  40. package/src/client-info.test.ts +214 -0
  41. package/src/client-info.ts +121 -0
  42. package/src/context.test.ts +73 -14
  43. package/src/context.ts +126 -22
  44. package/src/index.ts +12 -0
  45. package/src/types.ts +23 -0
  46. package/src/vault-client.ts +42 -1
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Tests for the client-info helpers and header injection.
3
+ *
4
+ * Two layers are exercised here:
5
+ * 1. The pure functions in `client-info.ts` — buildClientHeaders,
6
+ * clientInfoFromPackage, detectHqCoreVersion.
7
+ * 2. End-to-end injection into VaultClient.request, since "the headers
8
+ * actually land on outbound fetch" is the property consumers care about.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import {
16
+ buildClientHeaders,
17
+ clientInfoFromPackage,
18
+ detectHqCoreVersion,
19
+ HEADER_CLIENT_NAME,
20
+ HEADER_CLIENT_VERSION,
21
+ HEADER_HQ_CORE_VERSION,
22
+ } from "./client-info.js";
23
+ import { VaultClient } from "./vault-client.js";
24
+
25
+ describe("buildClientHeaders", () => {
26
+ it("returns empty object when info is undefined", () => {
27
+ expect(buildClientHeaders(undefined)).toEqual({});
28
+ });
29
+
30
+ it("emits User-Agent + x-hq-client-{name,version} from name/version", () => {
31
+ const headers = buildClientHeaders({
32
+ name: "@indigoai-us/hq-cli",
33
+ version: "5.15.0",
34
+ });
35
+ expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
36
+ expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
37
+ expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
38
+ expect(headers[HEADER_HQ_CORE_VERSION]).toBeUndefined();
39
+ });
40
+
41
+ it("includes x-hq-core-version only when hqCoreVersion is set", () => {
42
+ const headers = buildClientHeaders({
43
+ name: "@indigoai-us/hq-cli",
44
+ version: "5.15.0",
45
+ hqCoreVersion: "14.1.0",
46
+ });
47
+ expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
48
+ });
49
+
50
+ it("emits arbitrary extra fields as x-hq-client-<key> headers", () => {
51
+ const headers = buildClientHeaders({
52
+ name: "x",
53
+ version: "1.0.0",
54
+ extra: { machine: "ec2-bot-7", channel: "stable" },
55
+ });
56
+ expect(headers["x-hq-client-machine"]).toBe("ec2-bot-7");
57
+ expect(headers["x-hq-client-channel"]).toBe("stable");
58
+ });
59
+ });
60
+
61
+ describe("clientInfoFromPackage", () => {
62
+ it("extracts name and version from a parsed package.json", () => {
63
+ expect(clientInfoFromPackage({ name: "foo", version: "1.2.3" })).toEqual({
64
+ name: "foo",
65
+ version: "1.2.3",
66
+ });
67
+ });
68
+
69
+ it("throws when name is missing", () => {
70
+ expect(() => clientInfoFromPackage({ version: "1.0.0" })).toThrow(/name/);
71
+ });
72
+
73
+ it("throws when version is missing", () => {
74
+ expect(() => clientInfoFromPackage({ name: "foo" })).toThrow(/version/);
75
+ });
76
+
77
+ it("throws on non-string fields", () => {
78
+ expect(() =>
79
+ clientInfoFromPackage({ name: 42 as unknown as string, version: "1.0.0" }),
80
+ ).toThrow();
81
+ });
82
+ });
83
+
84
+ describe("detectHqCoreVersion", () => {
85
+ let tmpRoot: string;
86
+ const origHqHome = process.env.HQ_HOME;
87
+ const origHqRoot = process.env.HQ_ROOT;
88
+
89
+ beforeEach(() => {
90
+ tmpRoot = mkdtempSync(join(tmpdir(), "hq-core-detect-"));
91
+ delete process.env.HQ_HOME;
92
+ delete process.env.HQ_ROOT;
93
+ });
94
+
95
+ afterEach(() => {
96
+ rmSync(tmpRoot, { recursive: true, force: true });
97
+ if (origHqHome !== undefined) process.env.HQ_HOME = origHqHome;
98
+ if (origHqRoot !== undefined) process.env.HQ_ROOT = origHqRoot;
99
+ });
100
+
101
+ function seedHqCore(root: string, version: string): void {
102
+ mkdirSync(join(root, "core"), { recursive: true });
103
+ mkdirSync(join(root, "companies"), { recursive: true });
104
+ writeFileSync(
105
+ join(root, "core", "core.yaml"),
106
+ `version: 1\nhqVersion: "${version}"\nupdatedAt: "2026-05-13T00:00:00Z"\n`,
107
+ );
108
+ }
109
+
110
+ it("returns undefined when nothing on the walk-up has core.yaml", () => {
111
+ expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
112
+ });
113
+
114
+ it("returns hqVersion when startDir is the hq-core root", () => {
115
+ seedHqCore(tmpRoot, "14.1.0");
116
+ expect(detectHqCoreVersion(tmpRoot)).toBe("14.1.0");
117
+ });
118
+
119
+ it("walks upward to find core.yaml from a nested cwd", () => {
120
+ seedHqCore(tmpRoot, "14.2.0");
121
+ const nested = join(tmpRoot, "companies", "acme", "projects", "p1");
122
+ mkdirSync(nested, { recursive: true });
123
+ expect(detectHqCoreVersion(nested)).toBe("14.2.0");
124
+ });
125
+
126
+ it("ignores directories that have core.yaml but no companies/ — disambiguates fixtures", () => {
127
+ mkdirSync(join(tmpRoot, "core"), { recursive: true });
128
+ writeFileSync(
129
+ join(tmpRoot, "core", "core.yaml"),
130
+ `version: 1\nhqVersion: "99.0.0"\n`,
131
+ );
132
+ // No companies/ at this level → not an hq-core root.
133
+ expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
134
+ });
135
+
136
+ it("honors HQ_HOME env override before walking cwd", () => {
137
+ seedHqCore(tmpRoot, "14.3.0");
138
+ process.env.HQ_HOME = tmpRoot;
139
+ // startDir intentionally points elsewhere — env should win.
140
+ const elsewhere = mkdtempSync(join(tmpdir(), "elsewhere-"));
141
+ try {
142
+ expect(detectHqCoreVersion(elsewhere)).toBe("14.3.0");
143
+ } finally {
144
+ rmSync(elsewhere, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ it("parses unquoted hqVersion values too", () => {
149
+ mkdirSync(join(tmpRoot, "core"), { recursive: true });
150
+ mkdirSync(join(tmpRoot, "companies"), { recursive: true });
151
+ writeFileSync(
152
+ join(tmpRoot, "core", "core.yaml"),
153
+ `version: 1\nhqVersion: 14.4.0\n`,
154
+ );
155
+ expect(detectHqCoreVersion(tmpRoot)).toBe("14.4.0");
156
+ });
157
+ });
158
+
159
+ describe("VaultClient stamps client headers on outbound requests", () => {
160
+ afterEach(() => {
161
+ vi.restoreAllMocks();
162
+ });
163
+
164
+ it("includes x-hq-client-name + x-hq-client-version when clientInfo is set", async () => {
165
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
166
+ new Response(JSON.stringify({ memberships: [] }), {
167
+ status: 200,
168
+ headers: { "Content-Type": "application/json" },
169
+ }),
170
+ );
171
+
172
+ const client = new VaultClient({
173
+ apiUrl: "https://vault.test.example.com",
174
+ authToken: "tok",
175
+ clientInfo: {
176
+ name: "@indigoai-us/hq-cli",
177
+ version: "5.15.0",
178
+ hqCoreVersion: "14.1.0",
179
+ },
180
+ });
181
+
182
+ await client.listMyMemberships();
183
+
184
+ expect(fetchSpy).toHaveBeenCalledOnce();
185
+ const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
186
+ const headers = init.headers as Record<string, string>;
187
+ expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
188
+ expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
189
+ expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
190
+ expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
191
+ });
192
+
193
+ it("omits client headers when clientInfo is not set — back-compat", async () => {
194
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
195
+ new Response(JSON.stringify({ memberships: [] }), {
196
+ status: 200,
197
+ headers: { "Content-Type": "application/json" },
198
+ }),
199
+ );
200
+
201
+ const client = new VaultClient({
202
+ apiUrl: "https://vault.test.example.com",
203
+ authToken: "tok",
204
+ });
205
+
206
+ await client.listMyMemberships();
207
+
208
+ const headers = (fetchSpy.mock.calls[0]?.[1] as RequestInit)
209
+ .headers as Record<string, string>;
210
+ expect(headers[HEADER_CLIENT_NAME]).toBeUndefined();
211
+ expect(headers[HEADER_CLIENT_VERSION]).toBeUndefined();
212
+ expect(headers["User-Agent"]).toBeUndefined();
213
+ });
214
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Client identification — every HQ client that talks to hq-cloud-api should
3
+ * stamp its name and version on outbound requests so the server can attribute
4
+ * traffic, gate on minimum versions, and surface deprecation warnings.
5
+ *
6
+ * The CLI in particular sends a third header (`x-hq-core-version`) when it's
7
+ * invoked from inside an hq-core checkout, so the server sees which scaffold
8
+ * generation the user is running against.
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import type { ClientInfo } from "./types.js";
14
+
15
+ export const HEADER_CLIENT_NAME = "x-hq-client-name";
16
+ export const HEADER_CLIENT_VERSION = "x-hq-client-version";
17
+ export const HEADER_HQ_CORE_VERSION = "x-hq-core-version";
18
+
19
+ /**
20
+ * Build the set of `x-hq-*` headers (plus a derived `User-Agent`) for a
21
+ * ClientInfo. Returns an empty object when info is undefined so callers can
22
+ * spread the result unconditionally.
23
+ */
24
+ export function buildClientHeaders(
25
+ info: ClientInfo | undefined,
26
+ ): Record<string, string> {
27
+ if (!info) return {};
28
+
29
+ const headers: Record<string, string> = {
30
+ "User-Agent": `${info.name}/${info.version}`,
31
+ [HEADER_CLIENT_NAME]: info.name,
32
+ [HEADER_CLIENT_VERSION]: info.version,
33
+ };
34
+
35
+ if (info.hqCoreVersion) {
36
+ headers[HEADER_HQ_CORE_VERSION] = info.hqCoreVersion;
37
+ }
38
+
39
+ if (info.extra) {
40
+ for (const [k, v] of Object.entries(info.extra)) {
41
+ headers[`x-hq-client-${k}`] = v;
42
+ }
43
+ }
44
+
45
+ return headers;
46
+ }
47
+
48
+ /**
49
+ * Build a ClientInfo from a parsed package.json. Most consumers will call this
50
+ * once at startup and pass the result into every VaultServiceConfig.
51
+ */
52
+ export function clientInfoFromPackage(pkg: {
53
+ name?: unknown;
54
+ version?: unknown;
55
+ }): ClientInfo {
56
+ if (typeof pkg.name !== "string" || pkg.name.length === 0) {
57
+ throw new Error("clientInfoFromPackage: package.json is missing a name");
58
+ }
59
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) {
60
+ throw new Error("clientInfoFromPackage: package.json is missing a version");
61
+ }
62
+ return { name: pkg.name, version: pkg.version };
63
+ }
64
+
65
+ /**
66
+ * Walk upward from `startDir` looking for `core/core.yaml`. When found, parse
67
+ * out the `hqVersion` field and return it. Returns undefined if we never find
68
+ * an hq-core checkout — i.e. the caller is running from a plain user dir.
69
+ *
70
+ * Honors `HQ_HOME` env var as an explicit override so multi-checkout setups
71
+ * (e.g. CI bots, the menubar app pointing at a non-cwd HQ root) can pin the
72
+ * detection without relying on cwd.
73
+ *
74
+ * Why a regex instead of a YAML parser: `core.yaml` lives at the very top of
75
+ * the file and the field has a stable shape — adding a YAML dep just to read
76
+ * one string would balloon every consumer's bundle. The current shape is
77
+ * `hqVersion: "X.Y.Z"` (quoted) per the canonical seed; we tolerate unquoted
78
+ * too.
79
+ */
80
+ export function detectHqCoreVersion(startDir?: string): string | undefined {
81
+ const fromEnv = process.env.HQ_HOME ?? process.env.HQ_ROOT;
82
+ if (fromEnv) {
83
+ const v = readCoreVersionAt(fromEnv);
84
+ if (v) return v;
85
+ }
86
+
87
+ let dir = resolve(startDir ?? process.cwd());
88
+ while (true) {
89
+ const v = readCoreVersionAt(dir);
90
+ if (v) return v;
91
+ const parent = dirname(dir);
92
+ if (parent === dir) return undefined;
93
+ dir = parent;
94
+ }
95
+ }
96
+
97
+ function readCoreVersionAt(hqRoot: string): string | undefined {
98
+ // hq-core identity requires BOTH core/core.yaml AND companies/ — matches the
99
+ // CLI's own detection (commit bc827d0). Without this guard, any directory
100
+ // containing a stray `core/core.yaml` (e.g. a test fixture) would be
101
+ // misidentified as an hq-core root.
102
+ const yamlPath = join(hqRoot, "core", "core.yaml");
103
+ const companiesPath = join(hqRoot, "companies");
104
+ let content: string;
105
+ try {
106
+ content = readFileSync(yamlPath, "utf8");
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ try {
111
+ // statSync would be marginally cleaner, but readdirSync of a nonexistent
112
+ // path throws synchronously which is what we want.
113
+ readFileSync(companiesPath, { flag: "r" });
114
+ } catch (err) {
115
+ const e = err as NodeJS.ErrnoException;
116
+ // EISDIR is the success case — companies/ exists and is a directory.
117
+ if (e.code !== "EISDIR") return undefined;
118
+ }
119
+ const match = /^hqVersion:\s*["']?([^"'\s]+)/m.exec(content);
120
+ return match ? match[1] : undefined;
121
+ }
@@ -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 by-slug endpoint
101
- expect(fetchMock).toHaveBeenCalledTimes(2);
102
- expect(String(fetchMock.mock.calls[0][0])).toContain("/entity/by-slug/company/acme");
103
- expect(String(fetchMock.mock.calls[1][0])).toContain("/sts/vend");
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
- expect(fetchMock).toHaveBeenCalledTimes(2); // Only 1 entity + 1 vend call
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
- expect(fetchMock).toHaveBeenCalledTimes(4); // 2 entity + 2 vend calls
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 find entity/,
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/by-slug/company/foo_bar and credentials via /sts/vend", async () => {
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/by-slug/")) {
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(calls.some((u) => u.includes("/entity/by-slug/company/foo_bar"))).toBe(true);
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/by-slug/company/team_alpha and credentials via /sts/vend", async () => {
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/by-slug/")) {
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(calls.some((u) => u.includes("/entity/by-slug/company/team_alpha"))).toBe(true);
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
- expect(fetchMock).toHaveBeenCalledTimes(4); // 2 initial + 2 refresh
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