@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
package/src/context.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { EntityContext, VaultServiceConfig } from "./types.js";
10
+ import { buildClientHeaders } from "./client-info.js";
10
11
 
11
12
  /** Minimum remaining TTL before auto-refresh triggers (2 minutes). */
12
13
  const REFRESH_THRESHOLD_MS = 2 * 60 * 1000;
@@ -14,9 +15,56 @@ const REFRESH_THRESHOLD_MS = 2 * 60 * 1000;
14
15
  /** STS session duration requested from vault-service (15 minutes). */
15
16
  const DEFAULT_SESSION_DURATION_SECONDS = 900;
16
17
 
17
- /** Cached contexts keyed by entity UID. */
18
+ /**
19
+ * Cached contexts.
20
+ *
21
+ * Two keying schemes share the map:
22
+ * - UID keys: bare `cmp_xxx` / `prs_xxx`. Globally unique by ULID, so
23
+ * the cached context is safe to return to any caller asking by uid.
24
+ * - Slug keys: `slug:${callerSub}#${slug}`. Under the per-user-namespace
25
+ * model on hq-pro (PR 67, live 2026-05-15), the same slug can
26
+ * legitimately resolve to different company UIDs for different
27
+ * callers. Keying slug entries by `(callerSub, slug)` prevents one
28
+ * caller's resolution from being served to another caller against
29
+ * the wrong tenant. Codex flagged this as a P1 on PR 67's hq-cloud
30
+ * follow-up.
31
+ */
18
32
  const contextCache = new Map<string, EntityContext>();
19
33
 
34
+ /**
35
+ * Extract the Cognito sub claim from a JWT without verifying the
36
+ * signature. We don't need verification here — the token already
37
+ * authorized the upstream API call; we're only using `sub` as a
38
+ * per-caller cache discriminator. If the token is malformed or
39
+ * missing a sub, fall back to a hash of the whole token (slightly
40
+ * larger key space, same correctness — different tokens still
41
+ * get distinct cache entries).
42
+ */
43
+ function callerKeyFromAccessToken(accessToken: string): string {
44
+ const parts = accessToken.split(".");
45
+ if (parts.length === 3) {
46
+ try {
47
+ // base64url decode (Node-compatible, no padding required)
48
+ const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
49
+ const payload = Buffer.from(padded, "base64").toString("utf8");
50
+ const claims = JSON.parse(payload) as { sub?: unknown };
51
+ if (typeof claims.sub === "string" && claims.sub.length > 0) {
52
+ return claims.sub;
53
+ }
54
+ } catch {
55
+ // fall through to token-hash fallback
56
+ }
57
+ }
58
+ // Stable fallback — same token → same key, different tokens → different keys.
59
+ // (Avoids importing crypto for a sha; the raw token suffix is sufficient as
60
+ // a cache discriminator and isn't exposed outside this process.)
61
+ return `tok:${accessToken.slice(-32)}`;
62
+ }
63
+
64
+ function slugCacheKey(callerKey: string, slug: string): string {
65
+ return `slug:${callerKey}#${slug}`;
66
+ }
67
+
20
68
  /**
21
69
  * Closed-set of recognised entity-UID prefixes. Adding a new entity type
22
70
  * means appending one entry here AND extending the dispatch in
@@ -35,20 +83,34 @@ export async function resolveEntityContext(
35
83
  companyUidOrSlug: string,
36
84
  config: VaultServiceConfig,
37
85
  ): Promise<EntityContext> {
38
- // Check cache return if credentials still fresh
39
- const cached = contextCache.get(companyUidOrSlug);
40
- if (cached && !isExpiringSoon(cached.expiresAt)) {
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.
86
+ // Step 0: Determine whether the input is a UID (globally unique) or a
87
+ // slug (per-caller). The cache key differs accordingly — see the
88
+ // `contextCache` jsdoc.
47
89
  const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
48
90
  companyUidOrSlug.startsWith(p),
49
91
  );
50
92
  const looksLikePerson = companyUidOrSlug.startsWith("prs_");
51
93
 
94
+ // For slug lookups, scope the cache key by the caller. Resolving the
95
+ // access token here (once) doubles as a way to extract the sub for
96
+ // the key — same hop the downstream fetch makes anyway.
97
+ let cacheKey: string;
98
+ let callerKey: string | null = null;
99
+ if (looksLikeUid) {
100
+ cacheKey = companyUidOrSlug;
101
+ } else {
102
+ const token = await resolveAuthToken(config);
103
+ callerKey = callerKeyFromAccessToken(token);
104
+ cacheKey = slugCacheKey(callerKey, companyUidOrSlug);
105
+ }
106
+
107
+ // Check cache — return if credentials still fresh.
108
+ const cached = contextCache.get(cacheKey);
109
+ if (cached && !isExpiringSoon(cached.expiresAt)) {
110
+ return cached;
111
+ }
112
+
113
+ // Step 1: Resolve entity — direct fetch by UID or namespace lookup by slug.
52
114
  const entity = looksLikeUid
53
115
  ? await fetchEntity(companyUidOrSlug, config)
54
116
  : await fetchEntityBySlug("company", companyUidOrSlug, config);
@@ -81,9 +143,13 @@ export async function resolveEntityContext(
81
143
  expiresAt: vendResult.expiresAt,
82
144
  };
83
145
 
84
- // Cache by both UID and slug for fast lookups
146
+ // Cache by UID (globally unique) and if the caller asked by slug —
147
+ // by `(callerSub, slug)` so the same slug can resolve to different
148
+ // entities per caller without cross-caller poisoning.
85
149
  contextCache.set(entity.uid, ctx);
86
- contextCache.set(entity.slug, ctx);
150
+ if (callerKey) {
151
+ contextCache.set(slugCacheKey(callerKey, entity.slug), ctx);
152
+ }
87
153
 
88
154
  return ctx;
89
155
  }
@@ -105,8 +171,18 @@ export async function refreshEntityContext(
105
171
  companyUidOrSlug: string,
106
172
  config: VaultServiceConfig,
107
173
  ): Promise<EntityContext> {
108
- // Evict cache entry to force fresh resolution
109
- contextCache.delete(companyUidOrSlug);
174
+ // Evict the entry under whichever key shape applies. UID inputs key
175
+ // the cache by the bare uid; slug inputs key by `(callerSub, slug)`.
176
+ const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
177
+ companyUidOrSlug.startsWith(p),
178
+ );
179
+ if (looksLikeUid) {
180
+ contextCache.delete(companyUidOrSlug);
181
+ } else {
182
+ const token = await resolveAuthToken(config);
183
+ const callerKey = callerKeyFromAccessToken(token);
184
+ contextCache.delete(slugCacheKey(callerKey, companyUidOrSlug));
185
+ }
110
186
  return resolveEntityContext(companyUidOrSlug, config);
111
187
  }
112
188
 
@@ -156,7 +232,10 @@ async function fetchEntity(
156
232
  config: VaultServiceConfig,
157
233
  ): Promise<EntityResponse> {
158
234
  const res = await fetch(`${config.apiUrl}/entity/${uid}`, {
159
- headers: { Authorization: `Bearer ${await resolveAuthToken(config)}` },
235
+ headers: {
236
+ Authorization: `Bearer ${await resolveAuthToken(config)}`,
237
+ ...buildClientHeaders(config.clientInfo),
238
+ },
160
239
  });
161
240
  if (!res.ok) {
162
241
  const body = await res.text();
@@ -171,17 +250,41 @@ async function fetchEntityBySlug(
171
250
  slug: string,
172
251
  config: VaultServiceConfig,
173
252
  ): Promise<EntityResponse> {
174
- const res = await fetch(`${config.apiUrl}/entity/by-slug/${type}/${slug}`, {
175
- headers: { Authorization: `Bearer ${await resolveAuthToken(config)}` },
253
+ // Resolve the slug inside the CALLER's namespace via the new
254
+ // `/entity/check-slug/me` endpoint added in hq-pro PR 67 (live
255
+ // in prod 2026-05-15). Under the per-user-namespace model the
256
+ // legacy `/entity/by-slug/{type}/{slug}` either over-matches
257
+ // (returns another tenant's entity when more than one user holds
258
+ // the slug) or 409s with SlugNotUniqueError — both wrong for the
259
+ // sync runner's "I want MY company" intent.
260
+ const checkUrl = `${config.apiUrl}/entity/check-slug/me?type=${encodeURIComponent(type)}&slug=${encodeURIComponent(slug)}`;
261
+ const checkRes = await fetch(checkUrl, {
262
+ headers: {
263
+ Authorization: `Bearer ${await resolveAuthToken(config)}`,
264
+ ...buildClientHeaders(config.clientInfo),
265
+ },
176
266
  });
177
- if (!res.ok) {
178
- const body = await res.text();
267
+ if (!checkRes.ok) {
268
+ const body = await checkRes.text();
179
269
  throw new Error(
180
- `Failed to find entity by slug ${type}/${slug}: ${res.status} ${body}`,
270
+ `Failed to check slug ${type}/${slug}: ${checkRes.status} ${body}`,
181
271
  );
182
272
  }
183
- const data = (await res.json()) as { entity: EntityResponse };
184
- return data.entity;
273
+ const check = (await checkRes.json()) as {
274
+ available: boolean;
275
+ conflictingCompanyUid?: string;
276
+ };
277
+ if (check.available || !check.conflictingCompanyUid) {
278
+ // The slug isn't in the caller's namespace. From the sync
279
+ // runner's perspective this is a genuine "not found" — the
280
+ // caller doesn't have a `${slug}` to sync, even if some other
281
+ // user does.
282
+ throw new Error(
283
+ `No entity in caller's namespace matches ${type}/${slug}. ` +
284
+ `If you expected to find one, verify you're signed in to the right HQ identity.`,
285
+ );
286
+ }
287
+ return fetchEntity(check.conflictingCompanyUid, config);
185
288
  }
186
289
 
187
290
  async function postVend(
@@ -194,6 +297,7 @@ async function postVend(
194
297
  headers: {
195
298
  "Content-Type": "application/json",
196
299
  Authorization: `Bearer ${await resolveAuthToken(config)}`,
300
+ ...buildClientHeaders(config.clientInfo),
197
301
  },
198
302
  body: JSON.stringify({ ...body, durationSeconds: DEFAULT_SESSION_DURATION_SECONDS }),
199
303
  });
package/src/index.ts CHANGED
@@ -106,6 +106,7 @@ export type {
106
106
  EntityContext,
107
107
  VaultCredentials,
108
108
  VaultServiceConfig,
109
+ ClientInfo,
109
110
  SyncConfig,
110
111
  Credentials,
111
112
  JournalEntry,
@@ -115,3 +116,14 @@ export type {
115
116
  PullResult,
116
117
  DaemonState,
117
118
  } from "./types.js";
119
+
120
+ // Client identification — every first-party caller should construct one of
121
+ // these once at startup and pass it in via VaultServiceConfig.clientInfo.
122
+ export {
123
+ buildClientHeaders,
124
+ clientInfoFromPackage,
125
+ detectHqCoreVersion,
126
+ HEADER_CLIENT_NAME,
127
+ HEADER_CLIENT_VERSION,
128
+ HEADER_HQ_CORE_VERSION,
129
+ } from "./client-info.js";
package/src/types.ts CHANGED
@@ -91,6 +91,24 @@ export interface VaultCredentials {
91
91
  sessionToken: string;
92
92
  }
93
93
 
94
+ /**
95
+ * Caller identification stamped on every request to hq-cloud-api so the server
96
+ * can attribute traffic and gate on minimum versions.
97
+ */
98
+ export interface ClientInfo {
99
+ /** Package name, e.g. "@indigoai-us/hq-cli" */
100
+ name: string;
101
+ /** Package version, e.g. "5.15.0" */
102
+ version: string;
103
+ /**
104
+ * `hqVersion` from `core/core.yaml` — set when the caller is running inside
105
+ * an hq-core checkout. Lets the server see scaffold-generation skew.
106
+ */
107
+ hqCoreVersion?: string;
108
+ /** Arbitrary extra key/value pairs forwarded as `x-hq-client-<key>` headers. */
109
+ extra?: Record<string, string>;
110
+ }
111
+
94
112
  /**
95
113
  * Configuration for connecting to the vault-service API.
96
114
  */
@@ -110,6 +128,11 @@ export interface VaultServiceConfig {
110
128
  authToken: string | (() => string | Promise<string>);
111
129
  /** AWS region for S3 client (defaults to entity region or us-east-1) */
112
130
  region?: string;
131
+ /**
132
+ * Identifies the calling package + version on every outbound request.
133
+ * Optional for back-compat, but all first-party clients should pass it.
134
+ */
135
+ clientInfo?: ClientInfo;
113
136
  }
114
137
 
115
138
  // ── Conflict index (consumed by /resolve-conflicts) ─────────────────────────
@@ -6,7 +6,8 @@
6
6
  * share one client instead of each rolling its own HTTP layer.
7
7
  */
8
8
 
9
- import type { VaultServiceConfig } from "./types.js";
9
+ import type { ClientInfo, VaultServiceConfig } from "./types.js";
10
+ import { buildClientHeaders } from "./client-info.js";
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Error classes
@@ -213,6 +214,7 @@ async function sleep(ms: number): Promise<void> {
213
214
  export class VaultClient {
214
215
  private readonly apiUrl: string;
215
216
  private readonly getAuthToken: () => Promise<string>;
217
+ private readonly clientInfo: ClientInfo | undefined;
216
218
 
217
219
  constructor(config: VaultServiceConfig) {
218
220
  this.apiUrl = config.apiUrl.replace(/\/+$/, "");
@@ -227,6 +229,7 @@ export class VaultClient {
227
229
  typeof tok === "function"
228
230
  ? async () => tok()
229
231
  : async () => tok;
232
+ this.clientInfo = config.clientInfo;
230
233
  }
231
234
 
232
235
  // -- Membership operations ------------------------------------------------
@@ -332,6 +335,17 @@ export class VaultClient {
332
335
  return data.entity;
333
336
  },
334
337
 
338
+ /**
339
+ * Legacy global slug lookup. Under the per-user-namespace model on
340
+ * hq-pro (PR indigoai-us/hq-pro#67, live in prod 2026-05-15) the
341
+ * server-side handler now uses `requireUnique: true` — this method
342
+ * returns a single entity when only one tenant holds the slug, 404s
343
+ * when nobody does, or 409s with `SlugNotUniqueError` and a list of
344
+ * colliding `uids` when more than one tenant holds it. Most CLI
345
+ * call sites have moved to `findInMyNamespace` (which respects the
346
+ * caller's effective namespace); only flows that genuinely want a
347
+ * global lookup (admin tooling) should still use this method.
348
+ */
335
349
  findBySlug: async (type: string, slug: string): Promise<EntityInfo> => {
336
350
  const data = await this.get<{ entity: EntityInfo }>(
337
351
  `/entity/by-slug/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`,
@@ -339,6 +353,32 @@ export class VaultClient {
339
353
  return data.entity;
340
354
  },
341
355
 
356
+ /**
357
+ * Resolve an entity by slug within the CALLER's namespace
358
+ * (owned ∪ active-member-of, soft-deleted excluded). Hits the new
359
+ * `GET /entity/check-slug/me?type=&slug=` endpoint added in PR 67.
360
+ *
361
+ * Returns the full entity when present in the caller's namespace,
362
+ * or `null` when the slug isn't theirs — even if some OTHER user
363
+ * happens to own a company with the same slug. This is what every
364
+ * "find my-company by slug" flow wants under the per-user model;
365
+ * `findBySlug`'s global semantic would over-match (return a
366
+ * stranger's entity) or 409 (multi-tenant slug) in those cases.
367
+ */
368
+ findInMyNamespace: async (
369
+ type: string,
370
+ slug: string,
371
+ ): Promise<EntityInfo | null> => {
372
+ const check = await this.get<{
373
+ available: boolean;
374
+ conflictingCompanyUid?: string;
375
+ }>(
376
+ `/entity/check-slug/me?type=${encodeURIComponent(type)}&slug=${encodeURIComponent(slug)}`,
377
+ );
378
+ if (check.available || !check.conflictingCompanyUid) return null;
379
+ return this.entity.get(check.conflictingCompanyUid);
380
+ },
381
+
342
382
  create: async (input: CreateEntityInput): Promise<EntityInfo> => {
343
383
  const data = await this.post<CreateEntityResult>("/entity", input);
344
384
  return data.entity;
@@ -448,6 +488,7 @@ export class VaultClient {
448
488
  const headers: Record<string, string> = {
449
489
  Authorization: `Bearer ${await this.getAuthToken()}`,
450
490
  Accept: "application/json",
491
+ ...buildClientHeaders(this.clientInfo),
451
492
  };
452
493
 
453
494
  const init: RequestInit = { method, headers };