@indigoai-us/hq-cloud 5.17.0 → 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.
Files changed (53) hide show
  1. package/.github/workflows/ci.yml +19 -0
  2. package/.github/workflows/publish.yml +53 -0
  3. package/dist/cli/invite.js +4 -1
  4. package/dist/cli/invite.js.map +1 -1
  5. package/dist/cli/invite.test.js +3 -0
  6. package/dist/cli/invite.test.js.map +1 -1
  7. package/dist/cli/promote.js +3 -0
  8. package/dist/cli/promote.js.map +1 -1
  9. package/dist/cli/share.d.ts +7 -5
  10. package/dist/cli/share.d.ts.map +1 -1
  11. package/dist/cli/share.js +189 -18
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +304 -3
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts.map +1 -1
  16. package/dist/cli/sync.js +98 -17
  17. package/dist/cli/sync.js.map +1 -1
  18. package/dist/cli/sync.test.js +314 -0
  19. package/dist/cli/sync.test.js.map +1 -1
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +107 -18
  22. package/dist/context.js.map +1 -1
  23. package/dist/context.test.js +63 -14
  24. package/dist/context.test.js.map +1 -1
  25. package/dist/journal.d.ts +26 -0
  26. package/dist/journal.d.ts.map +1 -1
  27. package/dist/journal.js +31 -0
  28. package/dist/journal.js.map +1 -1
  29. package/dist/s3.d.ts +91 -0
  30. package/dist/s3.d.ts.map +1 -1
  31. package/dist/s3.js +245 -0
  32. package/dist/s3.js.map +1 -1
  33. package/dist/s3.test.js +347 -1
  34. package/dist/s3.test.js.map +1 -1
  35. package/dist/vault-client.d.ts +24 -0
  36. package/dist/vault-client.d.ts.map +1 -1
  37. package/dist/vault-client.js +29 -0
  38. package/dist/vault-client.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/invite.test.ts +3 -0
  41. package/src/cli/invite.ts +4 -1
  42. package/src/cli/promote.ts +3 -0
  43. package/src/cli/share.test.ts +377 -3
  44. package/src/cli/share.ts +241 -28
  45. package/src/cli/sync.test.ts +357 -0
  46. package/src/cli/sync.ts +133 -24
  47. package/src/context.test.ts +73 -14
  48. package/src/context.ts +116 -20
  49. package/src/journal.ts +33 -0
  50. package/src/s3.test.ts +415 -1
  51. package/src/s3.ts +271 -0
  52. package/src/vault-client.ts +37 -0
  53. package/tsconfig.json +12 -1
@@ -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
 
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
- /** Cached contexts keyed by entity UID. */
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
- // 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.
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 both UID and slug for fast lookups
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
- contextCache.set(entity.slug, ctx);
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 cache entry to force fresh resolution
109
- contextCache.delete(companyUidOrSlug);
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
- const res = await fetch(`${config.apiUrl}/entity/by-slug/${type}/${slug}`, {
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 (!res.ok) {
178
- const body = await res.text();
260
+ if (!checkRes.ok) {
261
+ const body = await checkRes.text();
179
262
  throw new Error(
180
- `Failed to find entity by slug ${type}/${slug}: ${res.status} ${body}`,
263
+ `Failed to check slug ${type}/${slug}: ${checkRes.status} ${body}`,
181
264
  );
182
265
  }
183
- const data = (await res.json()) as { entity: EntityResponse };
184
- return data.entity;
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/journal.ts CHANGED
@@ -74,6 +74,39 @@ export function hashFile(filePath: string): string {
74
74
  return crypto.createHash("sha256").update(content).digest("hex");
75
75
  }
76
76
 
77
+ /**
78
+ * Marker prepended to a symlink's target string before hashing for the
79
+ * journal. Mirrors the wire-side `SYMLINK_BODY_PREFIX` constant in
80
+ * `s3.ts` — same purpose, different namespace.
81
+ *
82
+ * Without this marker, a symlink to `real.md` and a regular file whose
83
+ * contents are exactly the bytes `real.md` produce identical journal
84
+ * hashes (both `sha256("real.md")`). When `skipUnchanged` is enabled,
85
+ * the planner would treat a regular-file → symlink replacement as
86
+ * "no change" and never upload the new symlink, leaving the remote
87
+ * representation stale forever — the pull side would then also see no
88
+ * drift via ETag and never repair.
89
+ *
90
+ * Hashing `sha256(prefix + target)` makes the two representations
91
+ * structurally inequal in journal-hash space, so skip-unchanged can
92
+ * never confuse them. The hash always varies with the target string,
93
+ * so target rewrites still re-fire uploads as expected.
94
+ */
95
+ export const SYMLINK_HASH_PREFIX = "hq-symlink:";
96
+
97
+ /**
98
+ * Compute the journal hash for a symlink. Always use this helper
99
+ * (never inline `crypto.createHash` with the raw target) so the
100
+ * push side, the pull-planner, and the post-download stamp stay in
101
+ * lockstep on the prefixed-hash convention.
102
+ */
103
+ export function hashSymlinkTarget(target: string): string {
104
+ return crypto
105
+ .createHash("sha256")
106
+ .update(SYMLINK_HASH_PREFIX + target)
107
+ .digest("hex");
108
+ }
109
+
77
110
  export function updateEntry(
78
111
  journal: SyncJournal,
79
112
  relativePath: string,