@indigoai-us/hq-cli 5.15.0 → 5.16.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.
@@ -111,7 +111,32 @@ export interface VaultClient {
111
111
  * entity.
112
112
  */
113
113
  listMyPersonEntities(): Promise<VaultEntity[]>;
114
+ /**
115
+ * Legacy global-uniqueness lookup. Under the per-user-namespace model
116
+ * (hq-pro 2026-05-15) this can return any tenant's entity when more
117
+ * than one user holds the same slug, OR `null` when the caller doesn't
118
+ * have it but a different user does. Kept on the interface for any
119
+ * remaining callers, but `provisionCompany` now uses
120
+ * `checkSlugInMyNamespace` instead — same-slug-different-owner is
121
+ * legitimate and should NOT trigger reuse of the stranger's entity.
122
+ */
114
123
  findCompanyBySlug(slug: string): Promise<VaultEntity | null>;
124
+ /**
125
+ * Caller-scoped slug availability check via
126
+ * `GET /entity/check-slug/me?type=company&slug=...`. Returns
127
+ * `{available: true}` when the caller's namespace
128
+ * (owned ∪ active-member-of, soft-deleted excluded) doesn't hold the
129
+ * slug, or `{available: false, conflictingCompanyUid}` when it does
130
+ * — `provisionCompany` reuses the `conflictingCompanyUid` as the
131
+ * idempotent entity instead of creating a duplicate.
132
+ */
133
+ checkSlugInMyNamespace(slug: string): Promise<{
134
+ available: boolean;
135
+ conflictingCompanyUid?: string;
136
+ }>;
137
+ /** Fetch a company entity by uid. Used to materialize the entity
138
+ * after `checkSlugInMyNamespace` reports a same-namespace collision. */
139
+ getCompanyByUid(uid: string): Promise<VaultEntity>;
115
140
  createCompanyEntity(input: {
116
141
  slug: string;
117
142
  name: string;
@@ -28,7 +28,7 @@
28
28
  * `initial_sync.ok=false`). Manifest + config may have been written.
29
29
  */
30
30
 
31
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3c9e69f3-8137-5db1-8692-e38774558f61")}catch(e){}}();
31
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="925baddc-7b12-5ad4-b64f-d7e20bd0daab")}catch(e){}}();
32
32
  import chalk from "chalk";
33
33
  import * as fs from "node:fs";
34
34
  import * as path from "node:path";
@@ -295,6 +295,28 @@ export function createDefaultVaultClient(apiUrl, accessToken) {
295
295
  }
296
296
  return data.entity;
297
297
  },
298
+ async checkSlugInMyNamespace(slug) {
299
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/check-slug/me?type=company&slug=${encodeURIComponent(slug)}`;
300
+ const res = await fetch(url, { method: "GET", headers });
301
+ if (!res.ok) {
302
+ const body = await safeBody(res);
303
+ throw new ProvisionError(1, `Vault GET /entity/check-slug/me failed: ${res.status} ${res.statusText} — ${body}`);
304
+ }
305
+ return (await res.json());
306
+ },
307
+ async getCompanyByUid(uid) {
308
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/${encodeURIComponent(uid)}`;
309
+ const res = await fetch(url, { method: "GET", headers });
310
+ if (!res.ok) {
311
+ const body = await safeBody(res);
312
+ throw new ProvisionError(1, `Vault GET /entity/${uid} failed: ${res.status} ${res.statusText} — ${body}`);
313
+ }
314
+ const data = (await res.json());
315
+ if (!data.entity) {
316
+ throw new ProvisionError(1, `Vault GET /entity/${uid} returned 200 with no entity body`);
317
+ }
318
+ return data.entity;
319
+ },
298
320
  async createCompanyEntity(input) {
299
321
  const url = `${apiUrl.replace(/\/$/, "")}/entity`;
300
322
  const body = {
@@ -311,9 +333,15 @@ export function createDefaultVaultClient(apiUrl, accessToken) {
311
333
  });
312
334
  if (!res.ok) {
313
335
  const text = await safeBody(res);
314
- // 409 means a concurrent client created it between our GET and POST —
315
- // surface it as a vault error. The orchestrator is responsible for
316
- // retrying GET if it wants idempotency on collisions.
336
+ // 409 SLUG_IN_USE_FOR_PERSON: the caller already has the slug
337
+ // in their namespace (owned active-member-of). Under the
338
+ // per-user-namespace model this is the new same-user-collision
339
+ // signal — distinct from the legacy global EntityAlreadyExists.
340
+ // The CLI normally reaches `createCompanyEntity` only after
341
+ // `checkSlugInMyNamespace` reported `available: true`, so a
342
+ // 409 here means a race between the pre-check and the POST.
343
+ // Surface the response body verbatim so the caller can see the
344
+ // `code` + `conflictingCompanyUid` and resolve / retry.
317
345
  throw new ProvisionError(1, `Vault POST /entity failed: ${res.status} ${res.statusText} — ${text}`);
318
346
  }
319
347
  const data = (await res.json());
@@ -393,13 +421,51 @@ export async function provisionCompany(options) {
393
421
  throw new ProvisionError(2, 'No person entity found for this Cognito identity. Run `hq onboard` first to create your HQ identity, then re-run `hq cloud provision company`. (Provision was halted before any cloud-side resources were created.)');
394
422
  }
395
423
  log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
396
- let entity = await vaultClient.findCompanyBySlug(options.slug);
424
+ // Per-user-namespace-aware reuse-or-create. Replaces the legacy
425
+ // global `findCompanyBySlug` lookup, which under the per-user model
426
+ // (hq-pro 2026-05-15) returns ANY tenant's entity when more than one
427
+ // user holds the same slug, OR null when a different user has it —
428
+ // both wrong for the CLI's "reuse mine, or create" intent.
429
+ //
430
+ // `--owner` override: `options.ownerUid`, when set, lets a caller
431
+ // create the entity under a DIFFERENT person's ownership (e.g. an
432
+ // admin provisioning on behalf of someone). `/entity/check-slug/me`
433
+ // answers about the CALLER's namespace, not the target owner's, so
434
+ // the pre-check is meaningless in that case. Codex P2 on PR 7
435
+ // flagged this. The gate: only run the namespace check when the
436
+ // owner is the caller (or defaulted to the caller — i.e. no
437
+ // --owner supplied). On override, fall through to
438
+ // `createCompanyEntity` and let the server's authoritative 409
439
+ // (which IS scoped to the target's namespace, per the
440
+ // callerIsOwner gate on POST /entity in hq-pro PR 67) surface any
441
+ // real conflict.
442
+ //
443
+ // `callerIsOwner` is `true` whenever `options.ownerUid` is unset
444
+ // (defaults to caller server-side) OR — when set — happens to
445
+ // match the caller's own person UID(s) from `listMyPersonEntities`.
446
+ const callerOwnedUids = new Set(persons.map((p) => p.uid));
447
+ const callerIsOwner = !options.ownerUid || callerOwnedUids.has(options.ownerUid);
448
+ let entity;
397
449
  let createdEntity = false;
398
- if (entity) {
399
- log(`reusing existing vault entity uid=${entity.uid}`);
450
+ if (callerIsOwner) {
451
+ const slugCheck = await vaultClient.checkSlugInMyNamespace(options.slug);
452
+ if (!slugCheck.available && slugCheck.conflictingCompanyUid) {
453
+ log(`reusing existing vault entity uid=${slugCheck.conflictingCompanyUid} (slug already in caller's namespace)`);
454
+ entity = await vaultClient.getCompanyByUid(slugCheck.conflictingCompanyUid);
455
+ }
456
+ else {
457
+ log(`slug available in caller's namespace — creating vault entity`);
458
+ entity = await vaultClient.createCompanyEntity({
459
+ slug: options.slug,
460
+ name: options.name ?? options.slug,
461
+ ownerUid: options.ownerUid,
462
+ });
463
+ createdEntity = true;
464
+ log(`created vault entity uid=${entity.uid}`);
465
+ }
400
466
  }
401
467
  else {
402
- log(`vault entity not found creating`);
468
+ log(`--owner ${options.ownerUid} differs from caller's person(s); skipping namespace pre-check (server authoritatively gates per-target-namespace)`);
403
469
  entity = await vaultClient.createCompanyEntity({
404
470
  slug: options.slug,
405
471
  name: options.name ?? options.slug,
@@ -549,4 +615,4 @@ export function registerCloudProvisionCommands(program) {
549
615
  });
550
616
  }
551
617
  //# sourceMappingURL=cloud-provision.js.map
552
- //# debugId=3c9e69f3-8137-5db1-8692-e38774558f61
618
+ //# debugId=925baddc-7b12-5ad4-b64f-d7e20bd0daab
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.15.0",
3
+ "version": "5.16.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -582,6 +582,20 @@ describe("provisionCompany", () => {
582
582
  listMyPersonEntities: vi.fn().mockResolvedValue([
583
583
  { uid: "prs_01H", type: "person", slug: "test-user", name: "Test User" },
584
584
  ]),
585
+ // Default to "slug available in caller's namespace" so happy-path
586
+ // tests fall through to createCompanyEntity. Reuse-path tests
587
+ // override with {available: false, conflictingCompanyUid: ...}
588
+ // and supply a getCompanyByUid that returns the existing entity.
589
+ checkSlugInMyNamespace: vi
590
+ .fn()
591
+ .mockResolvedValue({ available: true }),
592
+ getCompanyByUid: vi
593
+ .fn()
594
+ .mockRejectedValue(
595
+ new Error(
596
+ "getCompanyByUid called without an explicit per-test mock — happy path should never hit it",
597
+ ),
598
+ ),
585
599
  findCompanyBySlug: vi.fn().mockResolvedValue(null),
586
600
  createCompanyEntity: vi.fn(),
587
601
  ...overrides,
@@ -716,7 +730,13 @@ describe("provisionCompany", () => {
716
730
  kmsKeyId: null,
717
731
  };
718
732
  const vaultClient = makeVaultClient({
719
- findCompanyBySlug: vi.fn().mockResolvedValue(entity),
733
+ // Reuse path under the per-user-namespace model: checkSlugInMyNamespace
734
+ // reports `available: false` with the existing entity's uid, and
735
+ // getCompanyByUid materializes the full entity for downstream use.
736
+ checkSlugInMyNamespace: vi
737
+ .fn()
738
+ .mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
739
+ getCompanyByUid: vi.fn().mockResolvedValue(entity),
720
740
  createCompanyEntity: vi.fn(),
721
741
  });
722
742
  const result = await provisionCompany({
@@ -731,6 +751,7 @@ describe("provisionCompany", () => {
731
751
  expect(result.created_entity).toBe(false);
732
752
  expect(result.kms_key_id).toBeNull();
733
753
  expect(vaultClient.createCompanyEntity).not.toHaveBeenCalled();
754
+ expect(vaultClient.getCompanyByUid).toHaveBeenCalledWith(entity.uid);
734
755
  });
735
756
 
736
757
  it("throws code 1 when entity has no bucketName (incomplete provisioning)", async () => {
@@ -743,7 +764,11 @@ describe("provisionCompany", () => {
743
764
  // bucketName intentionally absent
744
765
  };
745
766
  const vaultClient = makeVaultClient({
746
- findCompanyBySlug: vi.fn().mockResolvedValue(entity),
767
+ // Same reuse-path mock shape as the idempotent-path test above.
768
+ checkSlugInMyNamespace: vi
769
+ .fn()
770
+ .mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
771
+ getCompanyByUid: vi.fn().mockResolvedValue(entity),
747
772
  });
748
773
  try {
749
774
  await provisionCompany({
@@ -133,7 +133,32 @@ export interface VaultClient {
133
133
  * entity.
134
134
  */
135
135
  listMyPersonEntities(): Promise<VaultEntity[]>;
136
+ /**
137
+ * Legacy global-uniqueness lookup. Under the per-user-namespace model
138
+ * (hq-pro 2026-05-15) this can return any tenant's entity when more
139
+ * than one user holds the same slug, OR `null` when the caller doesn't
140
+ * have it but a different user does. Kept on the interface for any
141
+ * remaining callers, but `provisionCompany` now uses
142
+ * `checkSlugInMyNamespace` instead — same-slug-different-owner is
143
+ * legitimate and should NOT trigger reuse of the stranger's entity.
144
+ */
136
145
  findCompanyBySlug(slug: string): Promise<VaultEntity | null>;
146
+ /**
147
+ * Caller-scoped slug availability check via
148
+ * `GET /entity/check-slug/me?type=company&slug=...`. Returns
149
+ * `{available: true}` when the caller's namespace
150
+ * (owned ∪ active-member-of, soft-deleted excluded) doesn't hold the
151
+ * slug, or `{available: false, conflictingCompanyUid}` when it does
152
+ * — `provisionCompany` reuses the `conflictingCompanyUid` as the
153
+ * idempotent entity instead of creating a duplicate.
154
+ */
155
+ checkSlugInMyNamespace(slug: string): Promise<{
156
+ available: boolean;
157
+ conflictingCompanyUid?: string;
158
+ }>;
159
+ /** Fetch a company entity by uid. Used to materialize the entity
160
+ * after `checkSlugInMyNamespace` reports a same-namespace collision. */
161
+ getCompanyByUid(uid: string): Promise<VaultEntity>;
137
162
  createCompanyEntity(input: {
138
163
  slug: string;
139
164
  name: string;
@@ -475,6 +500,45 @@ export function createDefaultVaultClient(
475
500
  }
476
501
  return data.entity;
477
502
  },
503
+ async checkSlugInMyNamespace(slug: string): Promise<{
504
+ available: boolean;
505
+ conflictingCompanyUid?: string;
506
+ }> {
507
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/check-slug/me?type=company&slug=${encodeURIComponent(
508
+ slug,
509
+ )}`;
510
+ const res = await fetch(url, { method: "GET", headers });
511
+ if (!res.ok) {
512
+ const body = await safeBody(res);
513
+ throw new ProvisionError(
514
+ 1,
515
+ `Vault GET /entity/check-slug/me failed: ${res.status} ${res.statusText} — ${body}`,
516
+ );
517
+ }
518
+ return (await res.json()) as {
519
+ available: boolean;
520
+ conflictingCompanyUid?: string;
521
+ };
522
+ },
523
+ async getCompanyByUid(uid: string): Promise<VaultEntity> {
524
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/${encodeURIComponent(uid)}`;
525
+ const res = await fetch(url, { method: "GET", headers });
526
+ if (!res.ok) {
527
+ const body = await safeBody(res);
528
+ throw new ProvisionError(
529
+ 1,
530
+ `Vault GET /entity/${uid} failed: ${res.status} ${res.statusText} — ${body}`,
531
+ );
532
+ }
533
+ const data = (await res.json()) as { entity?: VaultEntity };
534
+ if (!data.entity) {
535
+ throw new ProvisionError(
536
+ 1,
537
+ `Vault GET /entity/${uid} returned 200 with no entity body`,
538
+ );
539
+ }
540
+ return data.entity;
541
+ },
478
542
  async createCompanyEntity(input: {
479
543
  slug: string;
480
544
  name: string;
@@ -494,9 +558,15 @@ export function createDefaultVaultClient(
494
558
  });
495
559
  if (!res.ok) {
496
560
  const text = await safeBody(res);
497
- // 409 means a concurrent client created it between our GET and POST —
498
- // surface it as a vault error. The orchestrator is responsible for
499
- // retrying GET if it wants idempotency on collisions.
561
+ // 409 SLUG_IN_USE_FOR_PERSON: the caller already has the slug
562
+ // in their namespace (owned active-member-of). Under the
563
+ // per-user-namespace model this is the new same-user-collision
564
+ // signal — distinct from the legacy global EntityAlreadyExists.
565
+ // The CLI normally reaches `createCompanyEntity` only after
566
+ // `checkSlugInMyNamespace` reported `available: true`, so a
567
+ // 409 here means a race between the pre-check and the POST.
568
+ // Surface the response body verbatim so the caller can see the
569
+ // `code` + `conflictingCompanyUid` and resolve / retry.
500
570
  throw new ProvisionError(
501
571
  1,
502
572
  `Vault POST /entity failed: ${res.status} ${res.statusText} — ${text}`,
@@ -600,12 +670,57 @@ export async function provisionCompany(
600
670
  }
601
671
  log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
602
672
 
603
- let entity = await vaultClient.findCompanyBySlug(options.slug);
673
+ // Per-user-namespace-aware reuse-or-create. Replaces the legacy
674
+ // global `findCompanyBySlug` lookup, which under the per-user model
675
+ // (hq-pro 2026-05-15) returns ANY tenant's entity when more than one
676
+ // user holds the same slug, OR null when a different user has it —
677
+ // both wrong for the CLI's "reuse mine, or create" intent.
678
+ //
679
+ // `--owner` override: `options.ownerUid`, when set, lets a caller
680
+ // create the entity under a DIFFERENT person's ownership (e.g. an
681
+ // admin provisioning on behalf of someone). `/entity/check-slug/me`
682
+ // answers about the CALLER's namespace, not the target owner's, so
683
+ // the pre-check is meaningless in that case. Codex P2 on PR 7
684
+ // flagged this. The gate: only run the namespace check when the
685
+ // owner is the caller (or defaulted to the caller — i.e. no
686
+ // --owner supplied). On override, fall through to
687
+ // `createCompanyEntity` and let the server's authoritative 409
688
+ // (which IS scoped to the target's namespace, per the
689
+ // callerIsOwner gate on POST /entity in hq-pro PR 67) surface any
690
+ // real conflict.
691
+ //
692
+ // `callerIsOwner` is `true` whenever `options.ownerUid` is unset
693
+ // (defaults to caller server-side) OR — when set — happens to
694
+ // match the caller's own person UID(s) from `listMyPersonEntities`.
695
+ const callerOwnedUids = new Set(persons.map((p) => p.uid));
696
+ const callerIsOwner =
697
+ !options.ownerUid || callerOwnedUids.has(options.ownerUid);
698
+
699
+ let entity: VaultEntity;
604
700
  let createdEntity = false;
605
- if (entity) {
606
- log(`reusing existing vault entity uid=${entity.uid}`);
701
+ if (callerIsOwner) {
702
+ const slugCheck = await vaultClient.checkSlugInMyNamespace(options.slug);
703
+ if (!slugCheck.available && slugCheck.conflictingCompanyUid) {
704
+ log(
705
+ `reusing existing vault entity uid=${slugCheck.conflictingCompanyUid} (slug already in caller's namespace)`,
706
+ );
707
+ entity = await vaultClient.getCompanyByUid(
708
+ slugCheck.conflictingCompanyUid,
709
+ );
710
+ } else {
711
+ log(`slug available in caller's namespace — creating vault entity`);
712
+ entity = await vaultClient.createCompanyEntity({
713
+ slug: options.slug,
714
+ name: options.name ?? options.slug,
715
+ ownerUid: options.ownerUid,
716
+ });
717
+ createdEntity = true;
718
+ log(`created vault entity uid=${entity.uid}`);
719
+ }
607
720
  } else {
608
- log(`vault entity not found — creating`);
721
+ log(
722
+ `--owner ${options.ownerUid} differs from caller's person(s); skipping namespace pre-check (server authoritatively gates per-target-namespace)`,
723
+ );
609
724
  entity = await vaultClient.createCompanyEntity({
610
725
  slug: options.slug,
611
726
  name: options.name ?? options.slug,