@indigoai-us/hq-cloud 5.19.1 → 5.20.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 (128) hide show
  1. package/.github/workflows/ci.yml +8 -4
  2. package/.github/workflows/publish.yml +9 -3
  3. package/dist/bin/sync-runner.d.ts +9 -0
  4. package/dist/bin/sync-runner.d.ts.map +1 -1
  5. package/dist/bin/sync-runner.js +58 -0
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/entity-resolver.d.ts +48 -0
  8. package/dist/entity-resolver.d.ts.map +1 -0
  9. package/dist/entity-resolver.js +122 -0
  10. package/dist/entity-resolver.js.map +1 -0
  11. package/dist/entity-resolver.test.d.ts +10 -0
  12. package/dist/entity-resolver.test.d.ts.map +1 -0
  13. package/dist/entity-resolver.test.js +236 -0
  14. package/dist/entity-resolver.test.js.map +1 -0
  15. package/dist/index.d.ts +18 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +21 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/schemas/signal-types.d.ts +16 -0
  20. package/dist/schemas/signal-types.d.ts.map +1 -0
  21. package/dist/schemas/signal-types.js +30 -0
  22. package/dist/schemas/signal-types.js.map +1 -0
  23. package/dist/schemas/signal-types.test.d.ts +2 -0
  24. package/dist/schemas/signal-types.test.d.ts.map +1 -0
  25. package/dist/schemas/signal-types.test.js +65 -0
  26. package/dist/schemas/signal-types.test.js.map +1 -0
  27. package/dist/schemas/source-channels.d.ts +15 -0
  28. package/dist/schemas/source-channels.d.ts.map +1 -0
  29. package/dist/schemas/source-channels.js +28 -0
  30. package/dist/schemas/source-channels.js.map +1 -0
  31. package/dist/schemas/source-channels.test.d.ts +2 -0
  32. package/dist/schemas/source-channels.test.d.ts.map +1 -0
  33. package/dist/schemas/source-channels.test.js +65 -0
  34. package/dist/schemas/source-channels.test.js.map +1 -0
  35. package/dist/signals/get.d.ts +13 -0
  36. package/dist/signals/get.d.ts.map +1 -0
  37. package/dist/signals/get.js +74 -0
  38. package/dist/signals/get.js.map +1 -0
  39. package/dist/signals/get.test.d.ts +5 -0
  40. package/dist/signals/get.test.d.ts.map +1 -0
  41. package/dist/signals/get.test.js +170 -0
  42. package/dist/signals/get.test.js.map +1 -0
  43. package/dist/signals/internals.d.ts +16 -0
  44. package/dist/signals/internals.d.ts.map +1 -0
  45. package/dist/signals/internals.js +39 -0
  46. package/dist/signals/internals.js.map +1 -0
  47. package/dist/signals/list.d.ts +10 -0
  48. package/dist/signals/list.d.ts.map +1 -0
  49. package/dist/signals/list.js +76 -0
  50. package/dist/signals/list.js.map +1 -0
  51. package/dist/signals/list.test.d.ts +9 -0
  52. package/dist/signals/list.test.d.ts.map +1 -0
  53. package/dist/signals/list.test.js +227 -0
  54. package/dist/signals/list.test.js.map +1 -0
  55. package/dist/signals/parse.d.ts +8 -0
  56. package/dist/signals/parse.d.ts.map +1 -0
  57. package/dist/signals/parse.js +8 -0
  58. package/dist/signals/parse.js.map +1 -0
  59. package/dist/signals/types.d.ts +69 -0
  60. package/dist/signals/types.d.ts.map +1 -0
  61. package/dist/signals/types.js +10 -0
  62. package/dist/signals/types.js.map +1 -0
  63. package/dist/sources/get.d.ts +11 -0
  64. package/dist/sources/get.d.ts.map +1 -0
  65. package/dist/sources/get.js +67 -0
  66. package/dist/sources/get.js.map +1 -0
  67. package/dist/sources/get.test.d.ts +5 -0
  68. package/dist/sources/get.test.d.ts.map +1 -0
  69. package/dist/sources/get.test.js +132 -0
  70. package/dist/sources/get.test.js.map +1 -0
  71. package/dist/sources/internals.d.ts +16 -0
  72. package/dist/sources/internals.d.ts.map +1 -0
  73. package/dist/sources/internals.js +39 -0
  74. package/dist/sources/internals.js.map +1 -0
  75. package/dist/sources/list.d.ts +10 -0
  76. package/dist/sources/list.d.ts.map +1 -0
  77. package/dist/sources/list.js +76 -0
  78. package/dist/sources/list.js.map +1 -0
  79. package/dist/sources/list.test.d.ts +8 -0
  80. package/dist/sources/list.test.d.ts.map +1 -0
  81. package/dist/sources/list.test.js +198 -0
  82. package/dist/sources/list.test.js.map +1 -0
  83. package/dist/sources/parse.d.ts +18 -0
  84. package/dist/sources/parse.d.ts.map +1 -0
  85. package/dist/sources/parse.js +35 -0
  86. package/dist/sources/parse.js.map +1 -0
  87. package/dist/sources/types.d.ts +62 -0
  88. package/dist/sources/types.d.ts.map +1 -0
  89. package/dist/sources/types.js +8 -0
  90. package/dist/sources/types.js.map +1 -0
  91. package/dist/telemetry.d.ts +87 -0
  92. package/dist/telemetry.d.ts.map +1 -0
  93. package/dist/telemetry.js +349 -0
  94. package/dist/telemetry.js.map +1 -0
  95. package/dist/telemetry.test.d.ts +11 -0
  96. package/dist/telemetry.test.d.ts.map +1 -0
  97. package/dist/telemetry.test.js +309 -0
  98. package/dist/telemetry.test.js.map +1 -0
  99. package/dist/vault-client.d.ts +59 -0
  100. package/dist/vault-client.d.ts.map +1 -1
  101. package/dist/vault-client.js +37 -0
  102. package/dist/vault-client.js.map +1 -1
  103. package/package.json +5 -3
  104. package/src/bin/sync-runner.ts +73 -0
  105. package/src/entity-resolver.test.ts +307 -0
  106. package/src/entity-resolver.ts +173 -0
  107. package/src/index.ts +79 -0
  108. package/src/schemas/signal-types.test.ts +82 -0
  109. package/src/schemas/signal-types.ts +38 -0
  110. package/src/schemas/source-channels.test.ts +82 -0
  111. package/src/schemas/source-channels.ts +36 -0
  112. package/src/signals/get.test.ts +204 -0
  113. package/src/signals/get.ts +79 -0
  114. package/src/signals/internals.ts +46 -0
  115. package/src/signals/list.test.ts +283 -0
  116. package/src/signals/list.ts +92 -0
  117. package/src/signals/parse.ts +8 -0
  118. package/src/signals/types.ts +74 -0
  119. package/src/sources/get.test.ts +166 -0
  120. package/src/sources/get.ts +75 -0
  121. package/src/sources/internals.ts +46 -0
  122. package/src/sources/list.test.ts +247 -0
  123. package/src/sources/list.ts +95 -0
  124. package/src/sources/parse.ts +43 -0
  125. package/src/sources/types.ts +67 -0
  126. package/src/telemetry.test.ts +394 -0
  127. package/src/telemetry.ts +436 -0
  128. package/src/vault-client.ts +86 -0
@@ -83,6 +83,7 @@ import { share as defaultShare } from "../cli/share.js";
83
83
  import type { ShareOptions, ShareResult } from "../cli/share.js";
84
84
  import type { ConflictStrategy } from "../cli/conflict.js";
85
85
  import type { UploadAuthor } from "../s3.js";
86
+ import { collectAndSendTelemetry } from "../telemetry.js";
86
87
 
87
88
  /**
88
89
  * Sync direction for a run.
@@ -278,6 +279,15 @@ export interface RunnerDeps {
278
279
  sync?: (options: SyncOptions) => Promise<SyncResult>;
279
280
  /** Share function (push phase). Defaults to `cli/share.share`. */
280
281
  share?: (options: ShareOptions) => Promise<ShareResult>;
282
+ /**
283
+ * Telemetry collector — runs just before the `all-complete` emit. Default
284
+ * implementation calls `collectAndSendTelemetry` from `../telemetry.js`
285
+ * using the real VaultClient; tests that inject `createVaultClient` are
286
+ * implicitly opted out (the default skips when the client isn't a real
287
+ * `VaultClient`). Tests that want to assert telemetry behavior should pass
288
+ * an explicit stub here.
289
+ */
290
+ collectTelemetry?: () => Promise<void>;
281
291
  }
282
292
 
283
293
  // ---------------------------------------------------------------------------
@@ -445,6 +455,55 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
445
455
  return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
446
456
  }
447
457
 
458
+ // ---------------------------------------------------------------------------
459
+ // Telemetry default — closes over the runner's vault client. Skipped when
460
+ // the caller injected a `createVaultClient` stub, because we have no
461
+ // guarantee the stub implements `getTelemetryOptIn` / `postUsage`. The real
462
+ // `VaultClient` from `../vault-client.js` always does. All errors are
463
+ // swallowed — telemetry must never abort or delay sync.
464
+ // ---------------------------------------------------------------------------
465
+
466
+ async function defaultCollectTelemetry(
467
+ client: VaultClientSurface,
468
+ clientIsStub: boolean,
469
+ ): Promise<void> {
470
+ if (clientIsStub) return;
471
+ try {
472
+ // machineId: prefer ~/.hq/menubar.json (set by the menubar app on first
473
+ // launch). When absent — e.g. fresh CLI-only install — fall back to a
474
+ // value that makes the row identifiable as "unattributed" rather than
475
+ // crashing or spoofing another machine's id.
476
+ const menubarPath = path.join(os.homedir(), ".hq", "menubar.json");
477
+ let machineId = "unknown";
478
+ try {
479
+ const raw = await fs.promises.readFile(menubarPath, "utf-8");
480
+ const parsed = JSON.parse(raw) as { machineId?: unknown };
481
+ if (typeof parsed.machineId === "string" && parsed.machineId.length > 0) {
482
+ machineId = parsed.machineId;
483
+ }
484
+ } catch {
485
+ // No menubar.json — proceed with "unknown".
486
+ }
487
+
488
+ // installerVersion: callers (the Tauri menubar) set this when spawning
489
+ // the runner so the historical `installerVersion` dimension on
490
+ // CloudWatch keeps reporting the menubar version, not the runner's
491
+ // package version. CLI callers can leave it unset.
492
+ const installerVersion = process.env.HQ_INSTALLER_VERSION ?? "hq-cloud";
493
+
494
+ await collectAndSendTelemetry({
495
+ // The runtime guarantee here is that `clientIsStub === false` means
496
+ // `client` came from `new VaultClient(vaultConfig)` (see runRunner),
497
+ // which structurally satisfies `TelemetryClientSurface`.
498
+ client: client as unknown as Parameters<typeof collectAndSendTelemetry>[0]["client"],
499
+ machineId,
500
+ installerVersion,
501
+ });
502
+ } catch {
503
+ // Fire-and-forget; nothing escapes the boundary.
504
+ }
505
+ }
506
+
448
507
  // ---------------------------------------------------------------------------
449
508
  // runRunner — testable entrypoint
450
509
  // ---------------------------------------------------------------------------
@@ -922,6 +981,20 @@ export async function runRunner(
922
981
  });
923
982
  }
924
983
 
984
+ // Fire telemetry collector before the all-complete emit so the cursor at
985
+ // `~/.hq/telemetry-cursor.json` is consistent with what the menubar sees.
986
+ // Awaited fully — fire-and-forget would lose in-flight POSTs at process
987
+ // exit, and the previous 10s race was wrong: a first-run user with
988
+ // backlog (e.g. 60K Claude session events) takes well over 10s legitimately,
989
+ // and the race silently dropped the entire batch when it fired. Errors
990
+ // are swallowed inside `defaultCollectTelemetry`; per-request timeouts
991
+ // come from `VaultClient`'s retry loop (3 attempts × exponential backoff),
992
+ // which naturally bounds the outer wait.
993
+ const telemetryFn =
994
+ deps.collectTelemetry ??
995
+ (() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined));
996
+ await telemetryFn().catch(() => undefined);
997
+
925
998
  emit({
926
999
  type: "all-complete",
927
1000
  companiesAttempted: plan.length,
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Unit tests for entity-resolver.ts — slug → EntityContext resolution.
3
+ *
4
+ * Mocks globalThis.fetch to simulate vault-service responses for:
5
+ * GET /membership/me → list memberships
6
+ * GET /entity/{uid} → entity info (slug, bucketName)
7
+ * POST /entities/{uid}/sts → STS credentials
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
+ import {
12
+ resolveEntity,
13
+ listAvailableEntities,
14
+ EntityNotFoundError,
15
+ EntityPermissionError,
16
+ EntityResolutionError,
17
+ } from "./entity-resolver.js";
18
+ import type { VaultServiceConfig } from "./types.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Test fixtures
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const VAULT_CONFIG: VaultServiceConfig = {
25
+ apiUrl: "https://vault.test",
26
+ authToken: "test-jwt",
27
+ region: "us-east-1",
28
+ };
29
+
30
+ const ENTITIES = {
31
+ indigo: {
32
+ uid: "cmp_indigo_001",
33
+ slug: "indigo",
34
+ type: "company",
35
+ name: "Indigo",
36
+ bucketName: "hq-vault-indigo",
37
+ status: "active",
38
+ },
39
+ personal: {
40
+ uid: "cmp_personal_001",
41
+ slug: "personal",
42
+ type: "person",
43
+ name: "Stefan",
44
+ bucketName: "hq-vault-personal",
45
+ status: "active",
46
+ },
47
+ };
48
+
49
+ const STS_CREDS = {
50
+ credentials: {
51
+ accessKeyId: "ASIAMOCK000000000001",
52
+ secretAccessKey: "mockSecret",
53
+ sessionToken: "mockSession",
54
+ },
55
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
56
+ bucketName: "hq-vault-indigo",
57
+ region: "us-east-1",
58
+ };
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Mock helpers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function json(body: unknown, status = 200): Response {
65
+ return new Response(JSON.stringify(body), {
66
+ status,
67
+ headers: { "Content-Type": "application/json" },
68
+ });
69
+ }
70
+
71
+ interface MockOptions {
72
+ memberships?: Array<{ companyUid: string; role: string }>;
73
+ entities?: Record<string, typeof ENTITIES.indigo>;
74
+ stsStatus?: number;
75
+ stsBody?: unknown;
76
+ entityStatus?: number;
77
+ }
78
+
79
+ function setupFetchMock(opts: MockOptions = {}) {
80
+ const memberships = opts.memberships ?? [
81
+ { companyUid: "cmp_indigo_001", role: "owner" },
82
+ { companyUid: "cmp_personal_001", role: "owner" },
83
+ ];
84
+
85
+ const entities = opts.entities ?? {
86
+ cmp_indigo_001: ENTITIES.indigo,
87
+ cmp_personal_001: ENTITIES.personal,
88
+ };
89
+
90
+ const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
91
+ const url = typeof input === "string" ? input : input.toString();
92
+ const method = (init?.method ?? "GET").toUpperCase();
93
+
94
+ // GET /membership/me
95
+ if (method === "GET" && url.endsWith("/membership/me")) {
96
+ return json({
97
+ memberships: memberships.map((m) => ({
98
+ membershipKey: `mbr_${m.companyUid}`,
99
+ personUid: "prs_test_001",
100
+ companyUid: m.companyUid,
101
+ role: m.role,
102
+ status: "active",
103
+ invitedBy: "system",
104
+ invitedAt: "2026-01-01T00:00:00Z",
105
+ createdAt: "2026-01-01T00:00:00Z",
106
+ updatedAt: "2026-01-01T00:00:00Z",
107
+ })),
108
+ });
109
+ }
110
+
111
+ // GET /entity/{uid}
112
+ const entityGetMatch = /\/entity\/([^/?]+)$/.exec(url);
113
+ if (method === "GET" && entityGetMatch) {
114
+ const uid = decodeURIComponent(entityGetMatch[1]);
115
+ const entity = entities[uid as keyof typeof entities];
116
+ if (!entity || opts.entityStatus === 404) {
117
+ return json({ error: "Not found" }, opts.entityStatus ?? 404);
118
+ }
119
+ return json({ entity });
120
+ }
121
+
122
+ // POST /entities/{uid}/sts
123
+ const stsMatch = /\/entities\/([^/]+)\/sts/.exec(url);
124
+ if (method === "POST" && stsMatch) {
125
+ const uid = decodeURIComponent(stsMatch[1]);
126
+ if (opts.stsStatus && opts.stsStatus >= 400) {
127
+ const body = opts.stsBody ?? { error: `STS error ${opts.stsStatus}` };
128
+ return json(body, opts.stsStatus);
129
+ }
130
+ const entity = entities[uid as keyof typeof entities];
131
+ return json(opts.stsBody ?? {
132
+ ...STS_CREDS,
133
+ bucketName: entity?.bucketName ?? STS_CREDS.bucketName,
134
+ });
135
+ }
136
+
137
+ return json({ error: `Unhandled: ${method} ${url}` }, 500);
138
+ });
139
+
140
+ vi.stubGlobal("fetch", fetchMock);
141
+ return fetchMock;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Tests
146
+ // ---------------------------------------------------------------------------
147
+
148
+ beforeEach(() => {
149
+ vi.restoreAllMocks();
150
+ });
151
+
152
+ afterEach(() => {
153
+ vi.restoreAllMocks();
154
+ });
155
+
156
+ describe("resolveEntity", () => {
157
+ it("resolves entity by slug (happy path)", async () => {
158
+ setupFetchMock();
159
+
160
+ const ctx = await resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG });
161
+
162
+ expect(ctx.uid).toBe("cmp_indigo_001");
163
+ expect(ctx.slug).toBe("indigo");
164
+ expect(ctx.bucketName).toBe("hq-vault-indigo");
165
+ expect(ctx.region).toBe("us-east-1");
166
+ expect(ctx.credentials.accessKeyId).toBe("ASIAMOCK000000000001");
167
+ expect(ctx.credentials.secretAccessKey).toBe("mockSecret");
168
+ expect(ctx.credentials.sessionToken).toBe("mockSession");
169
+ expect(ctx.expiresAt).toBeTruthy();
170
+ });
171
+
172
+ it("resolves 'personal' slug without special-casing", async () => {
173
+ setupFetchMock();
174
+
175
+ const ctx = await resolveEntity({ slug: "personal", vaultConfig: VAULT_CONFIG });
176
+
177
+ expect(ctx.uid).toBe("cmp_personal_001");
178
+ expect(ctx.slug).toBe("personal");
179
+ expect(ctx.bucketName).toBe("hq-vault-personal");
180
+ });
181
+
182
+ it("throws EntityNotFoundError when slug not found", async () => {
183
+ setupFetchMock();
184
+
185
+ await expect(
186
+ resolveEntity({ slug: "unknown", vaultConfig: VAULT_CONFIG }),
187
+ ).rejects.toThrow(EntityNotFoundError);
188
+
189
+ try {
190
+ await resolveEntity({ slug: "unknown", vaultConfig: VAULT_CONFIG });
191
+ } catch (err) {
192
+ expect(err).toBeInstanceOf(EntityNotFoundError);
193
+ const e = err as EntityNotFoundError;
194
+ expect(e.message).toContain("unknown");
195
+ expect(e.message).toContain("indigo");
196
+ expect(e.message).toContain("personal");
197
+ }
198
+ });
199
+
200
+ it("throws EntityNotFoundError with (none) when user has no memberships", async () => {
201
+ setupFetchMock({ memberships: [] });
202
+
203
+ await expect(
204
+ resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG }),
205
+ ).rejects.toThrow(EntityNotFoundError);
206
+
207
+ try {
208
+ await resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG });
209
+ } catch (err) {
210
+ expect((err as Error).message).toContain("(none)");
211
+ }
212
+ });
213
+
214
+ it("throws EntityPermissionError on 403 from STS", async () => {
215
+ setupFetchMock({ stsStatus: 403, stsBody: { error: "Permission denied" } });
216
+
217
+ await expect(
218
+ resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG }),
219
+ ).rejects.toThrow(EntityPermissionError);
220
+ });
221
+
222
+ it("throws EntityResolutionError on 500 from STS", async () => {
223
+ setupFetchMock({ stsStatus: 500, stsBody: { error: "Internal error" } });
224
+
225
+ let caught: unknown;
226
+ try {
227
+ await resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG });
228
+ } catch (err) {
229
+ caught = err;
230
+ }
231
+
232
+ expect(caught).toBeInstanceOf(EntityResolutionError);
233
+ expect((caught as EntityResolutionError).statusCode).toBe(500);
234
+ }, 15_000);
235
+
236
+ it("rejects slug case-mismatch (case-sensitive)", async () => {
237
+ setupFetchMock();
238
+
239
+ await expect(
240
+ resolveEntity({ slug: "Indigo", vaultConfig: VAULT_CONFIG }),
241
+ ).rejects.toThrow(EntityNotFoundError);
242
+
243
+ try {
244
+ await resolveEntity({ slug: "Indigo", vaultConfig: VAULT_CONFIG });
245
+ } catch (err) {
246
+ expect((err as Error).message).toContain("Indigo");
247
+ expect((err as Error).message).toContain("indigo");
248
+ }
249
+ });
250
+
251
+ it("throws EntityResolutionError when entity has no bucket", async () => {
252
+ setupFetchMock({
253
+ entities: {
254
+ cmp_indigo_001: { ...ENTITIES.indigo, bucketName: undefined as unknown as string },
255
+ cmp_personal_001: ENTITIES.personal,
256
+ },
257
+ });
258
+
259
+ await expect(
260
+ resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG }),
261
+ ).rejects.toThrow(EntityResolutionError);
262
+
263
+ try {
264
+ await resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG });
265
+ } catch (err) {
266
+ expect((err as Error).message).toContain("no bucket provisioned");
267
+ }
268
+ });
269
+ });
270
+
271
+ describe("listAvailableEntities", () => {
272
+ it("returns all available entities with slug, uid, and role", async () => {
273
+ setupFetchMock();
274
+
275
+ const entities = await listAvailableEntities({ vaultConfig: VAULT_CONFIG });
276
+
277
+ expect(entities).toHaveLength(2);
278
+ expect(entities).toEqual(
279
+ expect.arrayContaining([
280
+ { slug: "indigo", uid: "cmp_indigo_001", role: "owner" },
281
+ { slug: "personal", uid: "cmp_personal_001", role: "owner" },
282
+ ]),
283
+ );
284
+ });
285
+
286
+ it("returns empty array when user has no memberships", async () => {
287
+ setupFetchMock({ memberships: [] });
288
+
289
+ const entities = await listAvailableEntities({ vaultConfig: VAULT_CONFIG });
290
+
291
+ expect(entities).toEqual([]);
292
+ });
293
+
294
+ it("preserves role from membership", async () => {
295
+ setupFetchMock({
296
+ memberships: [
297
+ { companyUid: "cmp_indigo_001", role: "member" },
298
+ ],
299
+ entities: { cmp_indigo_001: ENTITIES.indigo },
300
+ });
301
+
302
+ const entities = await listAvailableEntities({ vaultConfig: VAULT_CONFIG });
303
+
304
+ expect(entities).toHaveLength(1);
305
+ expect(entities[0].role).toBe("member");
306
+ });
307
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Entity resolver — maps a human-readable slug to an EntityContext with
3
+ * STS-vended credentials.
4
+ *
5
+ * Uses the membership-based discovery path (GET /membership/me → entity lookup
6
+ * → POST /entities/{uid}/sts) rather than JWT claims, which are fragile
7
+ * post-migration (see indigo-jwt-claims-fragile-post-migration).
8
+ */
9
+
10
+ import {
11
+ VaultClient,
12
+ VaultClientError,
13
+ VaultPermissionDeniedError,
14
+ } from "./vault-client.js";
15
+ import type { MembershipRole, EntityInfo } from "./vault-client.js";
16
+ import type { EntityContext, VaultServiceConfig } from "./types.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Error classes
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export class EntityNotFoundError extends Error {
23
+ constructor(slug: string, availableSlugs: string[]) {
24
+ const available = availableSlugs.length > 0
25
+ ? availableSlugs.join(", ")
26
+ : "(none)";
27
+ super(`Entity '${slug}' not found. Available: ${available}`);
28
+ this.name = "EntityNotFoundError";
29
+ }
30
+ }
31
+
32
+ export class EntityPermissionError extends Error {
33
+ constructor(slug: string, message?: string) {
34
+ super(message ?? `Permission denied for entity '${slug}'`);
35
+ this.name = "EntityPermissionError";
36
+ }
37
+ }
38
+
39
+ export class EntityResolutionError extends Error {
40
+ constructor(
41
+ message: string,
42
+ public readonly statusCode: number,
43
+ public readonly body?: string,
44
+ ) {
45
+ super(message);
46
+ this.name = "EntityResolutionError";
47
+ }
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Public types
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface AvailableEntity {
55
+ slug: string;
56
+ uid: string;
57
+ role: MembershipRole;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // resolveEntity
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Resolve a human-readable entity slug to a fully hydrated EntityContext
66
+ * with STS-scoped credentials ready for S3 operations.
67
+ *
68
+ * Flow:
69
+ * 1. GET /membership/me → list user's active memberships (companyUids)
70
+ * 2. GET /entity/{uid} for each → resolve slug + bucketName
71
+ * 3. Match the requested slug (case-sensitive)
72
+ * 4. POST /entities/{uid}/sts → vend STS credentials
73
+ */
74
+ export async function resolveEntity(opts: {
75
+ slug: string;
76
+ vaultConfig: VaultServiceConfig;
77
+ }): Promise<EntityContext> {
78
+ const client = new VaultClient(opts.vaultConfig);
79
+
80
+ // Step 1: List memberships
81
+ const memberships = await client.listMyMemberships();
82
+
83
+ if (memberships.length === 0) {
84
+ throw new EntityNotFoundError(opts.slug, []);
85
+ }
86
+
87
+ // Step 2: Resolve entity info for each membership (parallel)
88
+ const resolved = await Promise.all(
89
+ memberships.map(async (m) => {
90
+ const entity = await client.entity.get(m.companyUid);
91
+ return { entity, role: m.role };
92
+ }),
93
+ );
94
+
95
+ // Step 3: Find matching slug (case-sensitive)
96
+ const match = resolved.find(({ entity }) => entity.slug === opts.slug);
97
+
98
+ if (!match) {
99
+ const availableSlugs = resolved.map(({ entity }) => entity.slug);
100
+ throw new EntityNotFoundError(opts.slug, availableSlugs);
101
+ }
102
+
103
+ if (!match.entity.bucketName) {
104
+ throw new EntityResolutionError(
105
+ `Entity '${opts.slug}' (${match.entity.uid}) has no bucket provisioned`,
106
+ 500,
107
+ );
108
+ }
109
+
110
+ // Step 4: Vend STS credentials
111
+ let stsResult;
112
+ try {
113
+ stsResult = await client.sts.vendForEntity(match.entity.uid);
114
+ } catch (err) {
115
+ if (err instanceof VaultPermissionDeniedError) {
116
+ throw new EntityPermissionError(opts.slug, err.message);
117
+ }
118
+ if (err instanceof VaultClientError) {
119
+ throw new EntityResolutionError(
120
+ `STS vending failed for entity '${opts.slug}': ${err.message}`,
121
+ err.statusCode,
122
+ err.body,
123
+ );
124
+ }
125
+ throw err;
126
+ }
127
+
128
+ return {
129
+ uid: match.entity.uid,
130
+ slug: match.entity.slug,
131
+ bucketName: stsResult.bucketName,
132
+ region: stsResult.region,
133
+ credentials: {
134
+ accessKeyId: stsResult.credentials.accessKeyId,
135
+ secretAccessKey: stsResult.credentials.secretAccessKey,
136
+ sessionToken: stsResult.credentials.sessionToken,
137
+ },
138
+ expiresAt: stsResult.expiresAt,
139
+ };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // listAvailableEntities
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * List all entities the caller has access to, with slug, UID, and role.
148
+ * Used by `hq sources entities` / `hq signals entities` for discovery.
149
+ */
150
+ export async function listAvailableEntities(opts: {
151
+ vaultConfig: VaultServiceConfig;
152
+ }): Promise<AvailableEntity[]> {
153
+ const client = new VaultClient(opts.vaultConfig);
154
+
155
+ const memberships = await client.listMyMemberships();
156
+
157
+ if (memberships.length === 0) {
158
+ return [];
159
+ }
160
+
161
+ const resolved = await Promise.all(
162
+ memberships.map(async (m) => {
163
+ const entity = await client.entity.get(m.companyUid);
164
+ return {
165
+ slug: entity.slug,
166
+ uid: entity.uid,
167
+ role: m.role,
168
+ };
169
+ }),
170
+ );
171
+
172
+ return resolved;
173
+ }
package/src/index.ts CHANGED
@@ -77,8 +77,21 @@ export type {
77
77
  CreateEntityInput,
78
78
  CreateEntityResult,
79
79
  PendingInviteByEmail,
80
+ TelemetryOptInResponse,
81
+ UsageBatch,
82
+ UsageIngestResult,
80
83
  } from "./vault-client.js";
81
84
 
85
+ // Usage telemetry collector (`/v1/usage`). Re-exported so wrappers other than
86
+ // `hq-sync-runner` (mobile, custom CLIs) can run the collector on their own
87
+ // schedule without re-implementing the cursor + sanitizer.
88
+ export { collectAndSendTelemetry, sanitizeRow } from "./telemetry.js";
89
+ export type {
90
+ CollectTelemetryOptions,
91
+ CollectTelemetryResult,
92
+ TelemetryClientSurface,
93
+ } from "./telemetry.js";
94
+
82
95
  // STS child vending (VLT-8)
83
96
  export type {
84
97
  TaskAction,
@@ -127,3 +140,69 @@ export {
127
140
  HEADER_CLIENT_VERSION,
128
141
  HEADER_HQ_CORE_VERSION,
129
142
  } from "./client-info.js";
143
+
144
+ // Source-channel + signal-type schemas (SoT)
145
+ export {
146
+ SOURCE_CHANNELS,
147
+ isSourceChannel,
148
+ assertSourceChannel,
149
+ InvalidSourceChannelError,
150
+ } from "./schemas/source-channels.js";
151
+ export type { SourceChannel } from "./schemas/source-channels.js";
152
+
153
+ export {
154
+ SIGNAL_TYPES,
155
+ isSignalType,
156
+ assertSignalType,
157
+ InvalidSignalTypeError,
158
+ } from "./schemas/signal-types.js";
159
+ export type { SignalType } from "./schemas/signal-types.js";
160
+
161
+ // Entity resolver (sources/signals slug → EntityContext)
162
+ export {
163
+ resolveEntity,
164
+ listAvailableEntities,
165
+ EntityNotFoundError,
166
+ EntityPermissionError,
167
+ EntityResolutionError,
168
+ } from "./entity-resolver.js";
169
+ export type { AvailableEntity } from "./entity-resolver.js";
170
+
171
+ // Entity STS vending type
172
+ export type { EntityStsResult } from "./vault-client.js";
173
+
174
+ // Sources read surface
175
+ export { listSources } from "./sources/list.js";
176
+ export { getSource, SourceNotFoundError } from "./sources/get.js";
177
+ export type {
178
+ SourceSummary,
179
+ SourceDocument,
180
+ ListSourcesOptions,
181
+ ListSourcesResult,
182
+ GetSourceOptions,
183
+ } from "./sources/types.js";
184
+
185
+ // Test-only S3 factory hook for sources (consumed by hq-cli tests).
186
+ // Underscore prefix signals "not for production use".
187
+ export {
188
+ _setSourcesS3Factory,
189
+ _resetSourcesS3Factory,
190
+ } from "./sources/internals.js";
191
+
192
+ // Signals read surface
193
+ export { listSignals } from "./signals/list.js";
194
+ export { getSignal, SignalNotFoundError } from "./signals/get.js";
195
+ export type {
196
+ SignalSummary,
197
+ SignalDocument,
198
+ ListSignalsOptions,
199
+ ListSignalsResult,
200
+ GetSignalOptions,
201
+ } from "./signals/types.js";
202
+
203
+ // Test-only S3 factory hook for signals (consumed by hq-cli tests).
204
+ // Underscore prefix signals "not for production use".
205
+ export {
206
+ _setSignalsS3Factory,
207
+ _resetSignalsS3Factory,
208
+ } from "./signals/internals.js";