@indigoai-us/hq-cloud 5.19.0 → 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 (145) 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/client-info.d.ts +44 -0
  8. package/dist/client-info.d.ts.map +1 -0
  9. package/dist/client-info.js +112 -0
  10. package/dist/client-info.js.map +1 -0
  11. package/dist/client-info.test.d.ts +11 -0
  12. package/dist/client-info.test.d.ts.map +1 -0
  13. package/dist/client-info.test.js +168 -0
  14. package/dist/client-info.test.js.map +1 -0
  15. package/dist/context.d.ts.map +1 -1
  16. package/dist/context.js +10 -2
  17. package/dist/context.js.map +1 -1
  18. package/dist/entity-resolver.d.ts +48 -0
  19. package/dist/entity-resolver.d.ts.map +1 -0
  20. package/dist/entity-resolver.js +122 -0
  21. package/dist/entity-resolver.js.map +1 -0
  22. package/dist/entity-resolver.test.d.ts +10 -0
  23. package/dist/entity-resolver.test.d.ts.map +1 -0
  24. package/dist/entity-resolver.test.js +236 -0
  25. package/dist/entity-resolver.test.js.map +1 -0
  26. package/dist/index.d.ts +20 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +24 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/schemas/signal-types.d.ts +16 -0
  31. package/dist/schemas/signal-types.d.ts.map +1 -0
  32. package/dist/schemas/signal-types.js +30 -0
  33. package/dist/schemas/signal-types.js.map +1 -0
  34. package/dist/schemas/signal-types.test.d.ts +2 -0
  35. package/dist/schemas/signal-types.test.d.ts.map +1 -0
  36. package/dist/schemas/signal-types.test.js +65 -0
  37. package/dist/schemas/signal-types.test.js.map +1 -0
  38. package/dist/schemas/source-channels.d.ts +15 -0
  39. package/dist/schemas/source-channels.d.ts.map +1 -0
  40. package/dist/schemas/source-channels.js +28 -0
  41. package/dist/schemas/source-channels.js.map +1 -0
  42. package/dist/schemas/source-channels.test.d.ts +2 -0
  43. package/dist/schemas/source-channels.test.d.ts.map +1 -0
  44. package/dist/schemas/source-channels.test.js +65 -0
  45. package/dist/schemas/source-channels.test.js.map +1 -0
  46. package/dist/signals/get.d.ts +13 -0
  47. package/dist/signals/get.d.ts.map +1 -0
  48. package/dist/signals/get.js +74 -0
  49. package/dist/signals/get.js.map +1 -0
  50. package/dist/signals/get.test.d.ts +5 -0
  51. package/dist/signals/get.test.d.ts.map +1 -0
  52. package/dist/signals/get.test.js +170 -0
  53. package/dist/signals/get.test.js.map +1 -0
  54. package/dist/signals/internals.d.ts +16 -0
  55. package/dist/signals/internals.d.ts.map +1 -0
  56. package/dist/signals/internals.js +39 -0
  57. package/dist/signals/internals.js.map +1 -0
  58. package/dist/signals/list.d.ts +10 -0
  59. package/dist/signals/list.d.ts.map +1 -0
  60. package/dist/signals/list.js +76 -0
  61. package/dist/signals/list.js.map +1 -0
  62. package/dist/signals/list.test.d.ts +9 -0
  63. package/dist/signals/list.test.d.ts.map +1 -0
  64. package/dist/signals/list.test.js +227 -0
  65. package/dist/signals/list.test.js.map +1 -0
  66. package/dist/signals/parse.d.ts +8 -0
  67. package/dist/signals/parse.d.ts.map +1 -0
  68. package/dist/signals/parse.js +8 -0
  69. package/dist/signals/parse.js.map +1 -0
  70. package/dist/signals/types.d.ts +69 -0
  71. package/dist/signals/types.d.ts.map +1 -0
  72. package/dist/signals/types.js +10 -0
  73. package/dist/signals/types.js.map +1 -0
  74. package/dist/sources/get.d.ts +11 -0
  75. package/dist/sources/get.d.ts.map +1 -0
  76. package/dist/sources/get.js +67 -0
  77. package/dist/sources/get.js.map +1 -0
  78. package/dist/sources/get.test.d.ts +5 -0
  79. package/dist/sources/get.test.d.ts.map +1 -0
  80. package/dist/sources/get.test.js +132 -0
  81. package/dist/sources/get.test.js.map +1 -0
  82. package/dist/sources/internals.d.ts +16 -0
  83. package/dist/sources/internals.d.ts.map +1 -0
  84. package/dist/sources/internals.js +39 -0
  85. package/dist/sources/internals.js.map +1 -0
  86. package/dist/sources/list.d.ts +10 -0
  87. package/dist/sources/list.d.ts.map +1 -0
  88. package/dist/sources/list.js +76 -0
  89. package/dist/sources/list.js.map +1 -0
  90. package/dist/sources/list.test.d.ts +8 -0
  91. package/dist/sources/list.test.d.ts.map +1 -0
  92. package/dist/sources/list.test.js +198 -0
  93. package/dist/sources/list.test.js.map +1 -0
  94. package/dist/sources/parse.d.ts +18 -0
  95. package/dist/sources/parse.d.ts.map +1 -0
  96. package/dist/sources/parse.js +35 -0
  97. package/dist/sources/parse.js.map +1 -0
  98. package/dist/sources/types.d.ts +62 -0
  99. package/dist/sources/types.d.ts.map +1 -0
  100. package/dist/sources/types.js +8 -0
  101. package/dist/sources/types.js.map +1 -0
  102. package/dist/telemetry.d.ts +87 -0
  103. package/dist/telemetry.d.ts.map +1 -0
  104. package/dist/telemetry.js +349 -0
  105. package/dist/telemetry.js.map +1 -0
  106. package/dist/telemetry.test.d.ts +11 -0
  107. package/dist/telemetry.test.d.ts.map +1 -0
  108. package/dist/telemetry.test.js +309 -0
  109. package/dist/telemetry.test.js.map +1 -0
  110. package/dist/types.d.ts +22 -0
  111. package/dist/types.d.ts.map +1 -1
  112. package/dist/vault-client.d.ts +60 -0
  113. package/dist/vault-client.d.ts.map +1 -1
  114. package/dist/vault-client.js +41 -0
  115. package/dist/vault-client.js.map +1 -1
  116. package/package.json +5 -3
  117. package/src/bin/sync-runner.ts +73 -0
  118. package/src/client-info.test.ts +214 -0
  119. package/src/client-info.ts +121 -0
  120. package/src/context.ts +10 -2
  121. package/src/entity-resolver.test.ts +307 -0
  122. package/src/entity-resolver.ts +173 -0
  123. package/src/index.ts +91 -0
  124. package/src/schemas/signal-types.test.ts +82 -0
  125. package/src/schemas/signal-types.ts +38 -0
  126. package/src/schemas/source-channels.test.ts +82 -0
  127. package/src/schemas/source-channels.ts +36 -0
  128. package/src/signals/get.test.ts +204 -0
  129. package/src/signals/get.ts +79 -0
  130. package/src/signals/internals.ts +46 -0
  131. package/src/signals/list.test.ts +283 -0
  132. package/src/signals/list.ts +92 -0
  133. package/src/signals/parse.ts +8 -0
  134. package/src/signals/types.ts +74 -0
  135. package/src/sources/get.test.ts +166 -0
  136. package/src/sources/get.ts +75 -0
  137. package/src/sources/internals.ts +46 -0
  138. package/src/sources/list.test.ts +247 -0
  139. package/src/sources/list.ts +95 -0
  140. package/src/sources/parse.ts +43 -0
  141. package/src/sources/types.ts +67 -0
  142. package/src/telemetry.test.ts +394 -0
  143. package/src/telemetry.ts +436 -0
  144. package/src/types.ts +23 -0
  145. package/src/vault-client.ts +91 -1
@@ -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,
@@ -106,6 +119,7 @@ export type {
106
119
  EntityContext,
107
120
  VaultCredentials,
108
121
  VaultServiceConfig,
122
+ ClientInfo,
109
123
  SyncConfig,
110
124
  Credentials,
111
125
  JournalEntry,
@@ -115,3 +129,80 @@ export type {
115
129
  PullResult,
116
130
  DaemonState,
117
131
  } from "./types.js";
132
+
133
+ // Client identification — every first-party caller should construct one of
134
+ // these once at startup and pass it in via VaultServiceConfig.clientInfo.
135
+ export {
136
+ buildClientHeaders,
137
+ clientInfoFromPackage,
138
+ detectHqCoreVersion,
139
+ HEADER_CLIENT_NAME,
140
+ HEADER_CLIENT_VERSION,
141
+ HEADER_HQ_CORE_VERSION,
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";
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ SIGNAL_TYPES,
4
+ isSignalType,
5
+ assertSignalType,
6
+ InvalidSignalTypeError,
7
+ } from "./signal-types.js";
8
+ import type { SignalType } from "./signal-types.js";
9
+
10
+ describe("SIGNAL_TYPES", () => {
11
+ it("contains the six canonical types", () => {
12
+ expect([...SIGNAL_TYPES]).toEqual([
13
+ "action_item",
14
+ "commitment",
15
+ "decision",
16
+ "key_point",
17
+ "risk",
18
+ "summary",
19
+ ]);
20
+ });
21
+
22
+ it("is frozen (readonly at runtime)", () => {
23
+ expect(Object.isFrozen(SIGNAL_TYPES)).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe("isSignalType", () => {
28
+ it.each(SIGNAL_TYPES)("returns true for '%s'", (type) => {
29
+ expect(isSignalType(type)).toBe(true);
30
+ });
31
+
32
+ it("returns false for an invalid string", () => {
33
+ expect(isSignalType("ramble")).toBe(false);
34
+ });
35
+
36
+ it("returns false for non-string values", () => {
37
+ expect(isSignalType(42)).toBe(false);
38
+ expect(isSignalType(null)).toBe(false);
39
+ expect(isSignalType(undefined)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("assertSignalType", () => {
44
+ it.each(SIGNAL_TYPES)("does not throw for '%s'", (type) => {
45
+ expect(() => assertSignalType(type)).not.toThrow();
46
+ });
47
+
48
+ it("throws InvalidSignalTypeError for invalid value", () => {
49
+ expect(() => assertSignalType("ramble")).toThrow(InvalidSignalTypeError);
50
+ });
51
+
52
+ it("error message contains the offending value", () => {
53
+ expect(() => assertSignalType("ramble")).toThrow("'ramble'");
54
+ });
55
+
56
+ it("error message lists all valid types", () => {
57
+ try {
58
+ assertSignalType("ramble");
59
+ } catch (err) {
60
+ const msg = (err as Error).message;
61
+ for (const type of SIGNAL_TYPES) {
62
+ expect(msg).toContain(type);
63
+ }
64
+ }
65
+ });
66
+
67
+ it("error message matches snapshot", () => {
68
+ expect(() => assertSignalType("ramble")).toThrowErrorMatchingInlineSnapshot(
69
+ `[InvalidSignalTypeError: Invalid signal type: 'ramble'. Valid types: action_item, commitment, decision, key_point, risk, summary]`,
70
+ );
71
+ });
72
+ });
73
+
74
+ describe("SignalType type", () => {
75
+ it("narrows correctly via type guard", () => {
76
+ const value: unknown = "action_item";
77
+ if (isSignalType(value)) {
78
+ const _type: SignalType = value;
79
+ expect(_type).toBe("action_item");
80
+ }
81
+ });
82
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Signal type schema — single source of truth.
3
+ *
4
+ * Closed enum: the six canonical signal types.
5
+ * assertSignalType is the agent-mitigation lever — prevents LLMs
6
+ * from fabricating signal types.
7
+ */
8
+
9
+ export const SIGNAL_TYPES = Object.freeze([
10
+ "action_item",
11
+ "commitment",
12
+ "decision",
13
+ "key_point",
14
+ "risk",
15
+ "summary",
16
+ ] as const);
17
+
18
+ export type SignalType = (typeof SIGNAL_TYPES)[number];
19
+
20
+ export class InvalidSignalTypeError extends Error {
21
+ override readonly name = "InvalidSignalTypeError";
22
+
23
+ constructor(value: unknown) {
24
+ super(
25
+ `Invalid signal type: '${String(value)}'. Valid types: ${SIGNAL_TYPES.join(", ")}`,
26
+ );
27
+ }
28
+ }
29
+
30
+ export function isSignalType(value: unknown): value is SignalType {
31
+ return typeof value === "string" && (SIGNAL_TYPES as readonly string[]).includes(value);
32
+ }
33
+
34
+ export function assertSignalType(value: unknown): asserts value is SignalType {
35
+ if (!isSignalType(value)) {
36
+ throw new InvalidSignalTypeError(value);
37
+ }
38
+ }