@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.
- 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/client-info.d.ts +44 -0
- package/dist/client-info.d.ts.map +1 -0
- package/dist/client-info.js +112 -0
- package/dist/client-info.js.map +1 -0
- package/dist/client-info.test.d.ts +11 -0
- package/dist/client-info.test.d.ts.map +1 -0
- package/dist/client-info.test.js +168 -0
- package/dist/client-info.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +117 -20
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +63 -14
- package/dist/context.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +25 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +33 -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/client-info.test.ts +214 -0
- package/src/client-info.ts +121 -0
- package/src/context.test.ts +73 -14
- package/src/context.ts +126 -22
- package/src/index.ts +12 -0
- package/src/types.ts +23 -0
- 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
|
-
/**
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
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: {
|
|
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
|
-
|
|
175
|
-
|
|
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 (!
|
|
178
|
-
const body = await
|
|
267
|
+
if (!checkRes.ok) {
|
|
268
|
+
const body = await checkRes.text();
|
|
179
269
|
throw new Error(
|
|
180
|
-
`Failed to
|
|
270
|
+
`Failed to check slug ${type}/${slug}: ${checkRes.status} ${body}`,
|
|
181
271
|
);
|
|
182
272
|
}
|
|
183
|
-
const
|
|
184
|
-
|
|
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) ─────────────────────────
|
package/src/vault-client.ts
CHANGED
|
@@ -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 };
|