@indigoai-us/hq-cloud 5.19.1 → 5.21.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.
- package/.github/workflows/ci.yml +8 -4
- package/.github/workflows/publish.yml +9 -3
- package/dist/bin/sync-runner.d.ts +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +58 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/entity-resolver.d.ts +53 -0
- package/dist/entity-resolver.d.ts.map +1 -0
- package/dist/entity-resolver.js +127 -0
- package/dist/entity-resolver.js.map +1 -0
- package/dist/entity-resolver.test.d.ts +10 -0
- package/dist/entity-resolver.test.d.ts.map +1 -0
- package/dist/entity-resolver.test.js +244 -0
- package/dist/entity-resolver.test.js.map +1 -0
- package/dist/index.d.ts +17 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/signal-types.d.ts +16 -0
- package/dist/schemas/signal-types.d.ts.map +1 -0
- package/dist/schemas/signal-types.js +30 -0
- package/dist/schemas/signal-types.js.map +1 -0
- package/dist/schemas/signal-types.test.d.ts +2 -0
- package/dist/schemas/signal-types.test.d.ts.map +1 -0
- package/dist/schemas/signal-types.test.js +65 -0
- package/dist/schemas/signal-types.test.js.map +1 -0
- package/dist/schemas/source-channels.d.ts +15 -0
- package/dist/schemas/source-channels.d.ts.map +1 -0
- package/dist/schemas/source-channels.js +28 -0
- package/dist/schemas/source-channels.js.map +1 -0
- package/dist/schemas/source-channels.test.d.ts +2 -0
- package/dist/schemas/source-channels.test.d.ts.map +1 -0
- package/dist/schemas/source-channels.test.js +65 -0
- package/dist/schemas/source-channels.test.js.map +1 -0
- package/dist/signals/get.d.ts +13 -0
- package/dist/signals/get.d.ts.map +1 -0
- package/dist/signals/get.js +74 -0
- package/dist/signals/get.js.map +1 -0
- package/dist/signals/get.test.d.ts +5 -0
- package/dist/signals/get.test.d.ts.map +1 -0
- package/dist/signals/get.test.js +170 -0
- package/dist/signals/get.test.js.map +1 -0
- package/dist/signals/internals.d.ts +16 -0
- package/dist/signals/internals.d.ts.map +1 -0
- package/dist/signals/internals.js +39 -0
- package/dist/signals/internals.js.map +1 -0
- package/dist/signals/list.d.ts +10 -0
- package/dist/signals/list.d.ts.map +1 -0
- package/dist/signals/list.js +76 -0
- package/dist/signals/list.js.map +1 -0
- package/dist/signals/list.test.d.ts +9 -0
- package/dist/signals/list.test.d.ts.map +1 -0
- package/dist/signals/list.test.js +227 -0
- package/dist/signals/list.test.js.map +1 -0
- package/dist/signals/parse.d.ts +8 -0
- package/dist/signals/parse.d.ts.map +1 -0
- package/dist/signals/parse.js +8 -0
- package/dist/signals/parse.js.map +1 -0
- package/dist/signals/types.d.ts +69 -0
- package/dist/signals/types.d.ts.map +1 -0
- package/dist/signals/types.js +10 -0
- package/dist/signals/types.js.map +1 -0
- package/dist/sources/get.d.ts +11 -0
- package/dist/sources/get.d.ts.map +1 -0
- package/dist/sources/get.js +67 -0
- package/dist/sources/get.js.map +1 -0
- package/dist/sources/get.test.d.ts +5 -0
- package/dist/sources/get.test.d.ts.map +1 -0
- package/dist/sources/get.test.js +132 -0
- package/dist/sources/get.test.js.map +1 -0
- package/dist/sources/internals.d.ts +16 -0
- package/dist/sources/internals.d.ts.map +1 -0
- package/dist/sources/internals.js +39 -0
- package/dist/sources/internals.js.map +1 -0
- package/dist/sources/list.d.ts +10 -0
- package/dist/sources/list.d.ts.map +1 -0
- package/dist/sources/list.js +76 -0
- package/dist/sources/list.js.map +1 -0
- package/dist/sources/list.test.d.ts +8 -0
- package/dist/sources/list.test.d.ts.map +1 -0
- package/dist/sources/list.test.js +198 -0
- package/dist/sources/list.test.js.map +1 -0
- package/dist/sources/parse.d.ts +18 -0
- package/dist/sources/parse.d.ts.map +1 -0
- package/dist/sources/parse.js +35 -0
- package/dist/sources/parse.js.map +1 -0
- package/dist/sources/types.d.ts +62 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +8 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/telemetry.d.ts +87 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +349 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/telemetry.test.d.ts +11 -0
- package/dist/telemetry.test.d.ts.map +1 -0
- package/dist/telemetry.test.js +309 -0
- package/dist/telemetry.test.js.map +1 -0
- package/dist/vault-client.d.ts +43 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +28 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +5 -3
- package/src/bin/sync-runner.ts +73 -0
- package/src/entity-resolver.test.ts +315 -0
- package/src/entity-resolver.ts +180 -0
- package/src/index.ts +76 -0
- package/src/schemas/signal-types.test.ts +82 -0
- package/src/schemas/signal-types.ts +38 -0
- package/src/schemas/source-channels.test.ts +82 -0
- package/src/schemas/source-channels.ts +36 -0
- package/src/signals/get.test.ts +204 -0
- package/src/signals/get.ts +79 -0
- package/src/signals/internals.ts +46 -0
- package/src/signals/list.test.ts +283 -0
- package/src/signals/list.ts +92 -0
- package/src/signals/parse.ts +8 -0
- package/src/signals/types.ts +74 -0
- package/src/sources/get.test.ts +166 -0
- package/src/sources/get.ts +75 -0
- package/src/sources/internals.ts +46 -0
- package/src/sources/list.test.ts +247 -0
- package/src/sources/list.ts +95 -0
- package/src/sources/parse.ts +43 -0
- package/src/sources/types.ts +67 -0
- package/src/telemetry.test.ts +394 -0
- package/src/telemetry.ts +436 -0
- package/src/vault-client.ts +60 -0
|
@@ -0,0 +1,315 @@
|
|
|
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 { clearContextCache } from "./context.js";
|
|
12
|
+
import {
|
|
13
|
+
resolveEntity,
|
|
14
|
+
listAvailableEntities,
|
|
15
|
+
EntityNotFoundError,
|
|
16
|
+
EntityPermissionError,
|
|
17
|
+
EntityResolutionError,
|
|
18
|
+
} from "./entity-resolver.js";
|
|
19
|
+
import type { VaultServiceConfig } from "./types.js";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Test fixtures
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const VAULT_CONFIG: VaultServiceConfig = {
|
|
26
|
+
apiUrl: "https://vault.test",
|
|
27
|
+
authToken: "test-jwt",
|
|
28
|
+
region: "us-east-1",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ENTITIES = {
|
|
32
|
+
indigo: {
|
|
33
|
+
uid: "cmp_indigo_001",
|
|
34
|
+
slug: "indigo",
|
|
35
|
+
type: "company",
|
|
36
|
+
name: "Indigo",
|
|
37
|
+
bucketName: "hq-vault-indigo",
|
|
38
|
+
status: "active",
|
|
39
|
+
},
|
|
40
|
+
personal: {
|
|
41
|
+
uid: "cmp_personal_001",
|
|
42
|
+
slug: "personal",
|
|
43
|
+
type: "person",
|
|
44
|
+
name: "Stefan",
|
|
45
|
+
bucketName: "hq-vault-personal",
|
|
46
|
+
status: "active",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const STS_CREDS = {
|
|
51
|
+
credentials: {
|
|
52
|
+
accessKeyId: "ASIAMOCK000000000001",
|
|
53
|
+
secretAccessKey: "mockSecret",
|
|
54
|
+
sessionToken: "mockSession",
|
|
55
|
+
},
|
|
56
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
57
|
+
bucketName: "hq-vault-indigo",
|
|
58
|
+
region: "us-east-1",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Mock helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function json(body: unknown, status = 200): Response {
|
|
66
|
+
return new Response(JSON.stringify(body), {
|
|
67
|
+
status,
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface MockOptions {
|
|
73
|
+
memberships?: Array<{ companyUid: string; role: string }>;
|
|
74
|
+
entities?: Record<string, typeof ENTITIES.indigo>;
|
|
75
|
+
stsStatus?: number;
|
|
76
|
+
stsBody?: unknown;
|
|
77
|
+
entityStatus?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setupFetchMock(opts: MockOptions = {}) {
|
|
81
|
+
const memberships = opts.memberships ?? [
|
|
82
|
+
{ companyUid: "cmp_indigo_001", role: "owner" },
|
|
83
|
+
{ companyUid: "cmp_personal_001", role: "owner" },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const entities = opts.entities ?? {
|
|
87
|
+
cmp_indigo_001: ENTITIES.indigo,
|
|
88
|
+
cmp_personal_001: ENTITIES.personal,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
92
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
93
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
94
|
+
|
|
95
|
+
// GET /membership/me
|
|
96
|
+
if (method === "GET" && url.endsWith("/membership/me")) {
|
|
97
|
+
return json({
|
|
98
|
+
memberships: memberships.map((m) => ({
|
|
99
|
+
membershipKey: `mbr_${m.companyUid}`,
|
|
100
|
+
personUid: "prs_test_001",
|
|
101
|
+
companyUid: m.companyUid,
|
|
102
|
+
role: m.role,
|
|
103
|
+
status: "active",
|
|
104
|
+
invitedBy: "system",
|
|
105
|
+
invitedAt: "2026-01-01T00:00:00Z",
|
|
106
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
107
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
108
|
+
})),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// GET /entity/{uid}
|
|
113
|
+
const entityGetMatch = /\/entity\/([^/?]+)$/.exec(url);
|
|
114
|
+
if (method === "GET" && entityGetMatch) {
|
|
115
|
+
const uid = decodeURIComponent(entityGetMatch[1]);
|
|
116
|
+
const entity = entities[uid as keyof typeof entities];
|
|
117
|
+
if (!entity || opts.entityStatus === 404) {
|
|
118
|
+
return json({ error: "Not found" }, opts.entityStatus ?? 404);
|
|
119
|
+
}
|
|
120
|
+
return json({ entity });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// POST /sts/vend (canonical vault-service endpoint; was /entities/{uid}/sts
|
|
124
|
+
// in early drafts but that path doesn't actually exist server-side — the
|
|
125
|
+
// resolver now delegates to resolveEntityContext() which uses this route).
|
|
126
|
+
if (method === "POST" && url.endsWith("/sts/vend")) {
|
|
127
|
+
if (opts.stsStatus && opts.stsStatus >= 400) {
|
|
128
|
+
const body = opts.stsBody ?? { error: `STS error ${opts.stsStatus}` };
|
|
129
|
+
return json(body, opts.stsStatus);
|
|
130
|
+
}
|
|
131
|
+
// resolveEntityContext also re-fetches /entity/{uid} before vending, so
|
|
132
|
+
// the test mock above already handles that. Return creds + expiresAt;
|
|
133
|
+
// bucket/region come from the entity object + config respectively.
|
|
134
|
+
return json(opts.stsBody ?? {
|
|
135
|
+
credentials: STS_CREDS.credentials,
|
|
136
|
+
expiresAt: STS_CREDS.expiresAt,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return json({ error: `Unhandled: ${method} ${url}` }, 500);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
144
|
+
return fetchMock;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Tests
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
vi.restoreAllMocks();
|
|
153
|
+
// resolveEntity delegates to resolveEntityContext (context.ts), which
|
|
154
|
+
// caches by UID. The cache must be cleared between tests, otherwise a
|
|
155
|
+
// 200 response from the success test sticks around and the subsequent
|
|
156
|
+
// 403/500 tests see a cached EntityContext and never call fetch.
|
|
157
|
+
clearContextCache();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
vi.restoreAllMocks();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("resolveEntity", () => {
|
|
165
|
+
it("resolves entity by slug (happy path)", async () => {
|
|
166
|
+
setupFetchMock();
|
|
167
|
+
|
|
168
|
+
const ctx = await resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG });
|
|
169
|
+
|
|
170
|
+
expect(ctx.uid).toBe("cmp_indigo_001");
|
|
171
|
+
expect(ctx.slug).toBe("indigo");
|
|
172
|
+
expect(ctx.bucketName).toBe("hq-vault-indigo");
|
|
173
|
+
expect(ctx.region).toBe("us-east-1");
|
|
174
|
+
expect(ctx.credentials.accessKeyId).toBe("ASIAMOCK000000000001");
|
|
175
|
+
expect(ctx.credentials.secretAccessKey).toBe("mockSecret");
|
|
176
|
+
expect(ctx.credentials.sessionToken).toBe("mockSession");
|
|
177
|
+
expect(ctx.expiresAt).toBeTruthy();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("resolves 'personal' slug without special-casing", async () => {
|
|
181
|
+
setupFetchMock();
|
|
182
|
+
|
|
183
|
+
const ctx = await resolveEntity({ slug: "personal", vaultConfig: VAULT_CONFIG });
|
|
184
|
+
|
|
185
|
+
expect(ctx.uid).toBe("cmp_personal_001");
|
|
186
|
+
expect(ctx.slug).toBe("personal");
|
|
187
|
+
expect(ctx.bucketName).toBe("hq-vault-personal");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("throws EntityNotFoundError when slug not found", async () => {
|
|
191
|
+
setupFetchMock();
|
|
192
|
+
|
|
193
|
+
await expect(
|
|
194
|
+
resolveEntity({ slug: "unknown", vaultConfig: VAULT_CONFIG }),
|
|
195
|
+
).rejects.toThrow(EntityNotFoundError);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await resolveEntity({ slug: "unknown", vaultConfig: VAULT_CONFIG });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
expect(err).toBeInstanceOf(EntityNotFoundError);
|
|
201
|
+
const e = err as EntityNotFoundError;
|
|
202
|
+
expect(e.message).toContain("unknown");
|
|
203
|
+
expect(e.message).toContain("indigo");
|
|
204
|
+
expect(e.message).toContain("personal");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("throws EntityNotFoundError with (none) when user has no memberships", async () => {
|
|
209
|
+
setupFetchMock({ memberships: [] });
|
|
210
|
+
|
|
211
|
+
await expect(
|
|
212
|
+
resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG }),
|
|
213
|
+
).rejects.toThrow(EntityNotFoundError);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
expect((err as Error).message).toContain("(none)");
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("throws EntityPermissionError on 403 from STS", async () => {
|
|
223
|
+
setupFetchMock({ stsStatus: 403, stsBody: { error: "Permission denied" } });
|
|
224
|
+
|
|
225
|
+
await expect(
|
|
226
|
+
resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG }),
|
|
227
|
+
).rejects.toThrow(EntityPermissionError);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("throws EntityResolutionError on 500 from STS", async () => {
|
|
231
|
+
setupFetchMock({ stsStatus: 500, stsBody: { error: "Internal error" } });
|
|
232
|
+
|
|
233
|
+
let caught: unknown;
|
|
234
|
+
try {
|
|
235
|
+
await resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
caught = err;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
expect(caught).toBeInstanceOf(EntityResolutionError);
|
|
241
|
+
expect((caught as EntityResolutionError).statusCode).toBe(500);
|
|
242
|
+
}, 15_000);
|
|
243
|
+
|
|
244
|
+
it("rejects slug case-mismatch (case-sensitive)", async () => {
|
|
245
|
+
setupFetchMock();
|
|
246
|
+
|
|
247
|
+
await expect(
|
|
248
|
+
resolveEntity({ slug: "Indigo", vaultConfig: VAULT_CONFIG }),
|
|
249
|
+
).rejects.toThrow(EntityNotFoundError);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
await resolveEntity({ slug: "Indigo", vaultConfig: VAULT_CONFIG });
|
|
253
|
+
} catch (err) {
|
|
254
|
+
expect((err as Error).message).toContain("Indigo");
|
|
255
|
+
expect((err as Error).message).toContain("indigo");
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("throws EntityResolutionError when entity has no bucket", async () => {
|
|
260
|
+
setupFetchMock({
|
|
261
|
+
entities: {
|
|
262
|
+
cmp_indigo_001: { ...ENTITIES.indigo, bucketName: undefined as unknown as string },
|
|
263
|
+
cmp_personal_001: ENTITIES.personal,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG }),
|
|
269
|
+
).rejects.toThrow(EntityResolutionError);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await resolveEntity({ slug: "indigo", vaultConfig: VAULT_CONFIG });
|
|
273
|
+
} catch (err) {
|
|
274
|
+
expect((err as Error).message).toContain("no bucket provisioned");
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("listAvailableEntities", () => {
|
|
280
|
+
it("returns all available entities with slug, uid, and role", async () => {
|
|
281
|
+
setupFetchMock();
|
|
282
|
+
|
|
283
|
+
const entities = await listAvailableEntities({ vaultConfig: VAULT_CONFIG });
|
|
284
|
+
|
|
285
|
+
expect(entities).toHaveLength(2);
|
|
286
|
+
expect(entities).toEqual(
|
|
287
|
+
expect.arrayContaining([
|
|
288
|
+
{ slug: "indigo", uid: "cmp_indigo_001", role: "owner" },
|
|
289
|
+
{ slug: "personal", uid: "cmp_personal_001", role: "owner" },
|
|
290
|
+
]),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("returns empty array when user has no memberships", async () => {
|
|
295
|
+
setupFetchMock({ memberships: [] });
|
|
296
|
+
|
|
297
|
+
const entities = await listAvailableEntities({ vaultConfig: VAULT_CONFIG });
|
|
298
|
+
|
|
299
|
+
expect(entities).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("preserves role from membership", async () => {
|
|
303
|
+
setupFetchMock({
|
|
304
|
+
memberships: [
|
|
305
|
+
{ companyUid: "cmp_indigo_001", role: "member" },
|
|
306
|
+
],
|
|
307
|
+
entities: { cmp_indigo_001: ENTITIES.indigo },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const entities = await listAvailableEntities({ vaultConfig: VAULT_CONFIG });
|
|
311
|
+
|
|
312
|
+
expect(entities).toHaveLength(1);
|
|
313
|
+
expect(entities[0].role).toBe("member");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
* rather than JWT claims, which are fragile post-migration (see
|
|
7
|
+
* indigo-jwt-claims-fragile-post-migration). Delegates STS vending to the
|
|
8
|
+
* canonical `resolveEntityContext()` in context.ts so we use the actual
|
|
9
|
+
* vault-service endpoint (POST /sts/vend) rather than a made-up one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
VaultClient,
|
|
14
|
+
VaultClientError,
|
|
15
|
+
VaultPermissionDeniedError,
|
|
16
|
+
} from "./vault-client.js";
|
|
17
|
+
import type { MembershipRole } from "./vault-client.js";
|
|
18
|
+
import type { EntityContext, VaultServiceConfig } from "./types.js";
|
|
19
|
+
import { resolveEntityContext } from "./context.js";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Error classes
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export class EntityNotFoundError extends Error {
|
|
26
|
+
constructor(slug: string, availableSlugs: string[]) {
|
|
27
|
+
const available = availableSlugs.length > 0
|
|
28
|
+
? availableSlugs.join(", ")
|
|
29
|
+
: "(none)";
|
|
30
|
+
super(`Entity '${slug}' not found. Available: ${available}`);
|
|
31
|
+
this.name = "EntityNotFoundError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class EntityPermissionError extends Error {
|
|
36
|
+
constructor(slug: string, message?: string) {
|
|
37
|
+
super(message ?? `Permission denied for entity '${slug}'`);
|
|
38
|
+
this.name = "EntityPermissionError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class EntityResolutionError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
message: string,
|
|
45
|
+
public readonly statusCode: number,
|
|
46
|
+
public readonly body?: string,
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "EntityResolutionError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Public types
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export interface AvailableEntity {
|
|
58
|
+
slug: string;
|
|
59
|
+
uid: string;
|
|
60
|
+
role: MembershipRole;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// resolveEntity
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a human-readable entity slug to a fully hydrated EntityContext
|
|
69
|
+
* with STS-scoped credentials ready for S3 operations.
|
|
70
|
+
*
|
|
71
|
+
* Flow:
|
|
72
|
+
* 1. GET /membership/me → list user's active memberships (companyUids)
|
|
73
|
+
* 2. GET /entity/{uid} for each → resolve slug + bucketName
|
|
74
|
+
* 3. Match the requested slug (case-sensitive) — throw EntityNotFoundError
|
|
75
|
+
* with the list of available slugs if it doesn't match (better UX than
|
|
76
|
+
* the raw 404 from /entity/by-slug/{slug}).
|
|
77
|
+
* 4. Delegate to resolveEntityContext(uid) → POST /sts/vend (the actual
|
|
78
|
+
* vault-service endpoint).
|
|
79
|
+
*/
|
|
80
|
+
export async function resolveEntity(opts: {
|
|
81
|
+
slug: string;
|
|
82
|
+
vaultConfig: VaultServiceConfig;
|
|
83
|
+
}): Promise<EntityContext> {
|
|
84
|
+
const client = new VaultClient(opts.vaultConfig);
|
|
85
|
+
|
|
86
|
+
// Step 1: List memberships
|
|
87
|
+
const memberships = await client.listMyMemberships();
|
|
88
|
+
|
|
89
|
+
if (memberships.length === 0) {
|
|
90
|
+
throw new EntityNotFoundError(opts.slug, []);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Step 2: Resolve entity info for each membership (parallel)
|
|
94
|
+
const resolved = await Promise.all(
|
|
95
|
+
memberships.map(async (m) => {
|
|
96
|
+
const entity = await client.entity.get(m.companyUid);
|
|
97
|
+
return { entity, role: m.role };
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Step 3: Find matching slug (case-sensitive)
|
|
102
|
+
const match = resolved.find(({ entity }) => entity.slug === opts.slug);
|
|
103
|
+
|
|
104
|
+
if (!match) {
|
|
105
|
+
const availableSlugs = resolved.map(({ entity }) => entity.slug);
|
|
106
|
+
throw new EntityNotFoundError(opts.slug, availableSlugs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!match.entity.bucketName) {
|
|
110
|
+
throw new EntityResolutionError(
|
|
111
|
+
`Entity '${opts.slug}' (${match.entity.uid}) has no bucket provisioned`,
|
|
112
|
+
500,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Step 4: Vend STS credentials via the canonical context resolver.
|
|
117
|
+
// resolveEntityContext re-fetches the entity by UID (one extra hop) and
|
|
118
|
+
// calls POST /sts/vend (the actual endpoint) — re-using its caching +
|
|
119
|
+
// refresh logic is worth more than the redundant fetch.
|
|
120
|
+
try {
|
|
121
|
+
return await resolveEntityContext(match.entity.uid, opts.vaultConfig);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof VaultPermissionDeniedError) {
|
|
124
|
+
throw new EntityPermissionError(opts.slug, err.message);
|
|
125
|
+
}
|
|
126
|
+
if (err instanceof VaultClientError) {
|
|
127
|
+
throw new EntityResolutionError(
|
|
128
|
+
`STS vending failed for entity '${opts.slug}': ${err.message}`,
|
|
129
|
+
err.statusCode,
|
|
130
|
+
err.body,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
// resolveEntityContext throws plain Error on STS HTTP failures
|
|
134
|
+
// (`STS /sts/vend failed: <status> <body>`). Extract the status code
|
|
135
|
+
// and surface the right typed error for the caller.
|
|
136
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
137
|
+
const statusMatch = /STS\s+\S+\s+failed:\s+(\d{3})\b/i.exec(msg);
|
|
138
|
+
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 500;
|
|
139
|
+
if (statusCode === 403) {
|
|
140
|
+
throw new EntityPermissionError(opts.slug, msg);
|
|
141
|
+
}
|
|
142
|
+
throw new EntityResolutionError(
|
|
143
|
+
`STS vending failed for entity '${opts.slug}': ${msg}`,
|
|
144
|
+
statusCode,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// listAvailableEntities
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* List all entities the caller has access to, with slug, UID, and role.
|
|
155
|
+
* Used by `hq sources entities` / `hq signals entities` for discovery.
|
|
156
|
+
*/
|
|
157
|
+
export async function listAvailableEntities(opts: {
|
|
158
|
+
vaultConfig: VaultServiceConfig;
|
|
159
|
+
}): Promise<AvailableEntity[]> {
|
|
160
|
+
const client = new VaultClient(opts.vaultConfig);
|
|
161
|
+
|
|
162
|
+
const memberships = await client.listMyMemberships();
|
|
163
|
+
|
|
164
|
+
if (memberships.length === 0) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const resolved = await Promise.all(
|
|
169
|
+
memberships.map(async (m) => {
|
|
170
|
+
const entity = await client.entity.get(m.companyUid);
|
|
171
|
+
return {
|
|
172
|
+
slug: entity.slug,
|
|
173
|
+
uid: entity.uid,
|
|
174
|
+
role: m.role,
|
|
175
|
+
};
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return resolved;
|
|
180
|
+
}
|
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,66 @@ 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
|
+
// Sources read surface
|
|
172
|
+
export { listSources } from "./sources/list.js";
|
|
173
|
+
export { getSource, SourceNotFoundError } from "./sources/get.js";
|
|
174
|
+
export type {
|
|
175
|
+
SourceSummary,
|
|
176
|
+
SourceDocument,
|
|
177
|
+
ListSourcesOptions,
|
|
178
|
+
ListSourcesResult,
|
|
179
|
+
GetSourceOptions,
|
|
180
|
+
} from "./sources/types.js";
|
|
181
|
+
|
|
182
|
+
// Test-only S3 factory hook for sources (consumed by hq-cli tests).
|
|
183
|
+
// Underscore prefix signals "not for production use".
|
|
184
|
+
export {
|
|
185
|
+
_setSourcesS3Factory,
|
|
186
|
+
_resetSourcesS3Factory,
|
|
187
|
+
} from "./sources/internals.js";
|
|
188
|
+
|
|
189
|
+
// Signals read surface
|
|
190
|
+
export { listSignals } from "./signals/list.js";
|
|
191
|
+
export { getSignal, SignalNotFoundError } from "./signals/get.js";
|
|
192
|
+
export type {
|
|
193
|
+
SignalSummary,
|
|
194
|
+
SignalDocument,
|
|
195
|
+
ListSignalsOptions,
|
|
196
|
+
ListSignalsResult,
|
|
197
|
+
GetSignalOptions,
|
|
198
|
+
} from "./signals/types.js";
|
|
199
|
+
|
|
200
|
+
// Test-only S3 factory hook for signals (consumed by hq-cli tests).
|
|
201
|
+
// Underscore prefix signals "not for production use".
|
|
202
|
+
export {
|
|
203
|
+
_setSignalsS3Factory,
|
|
204
|
+
_resetSignalsS3Factory,
|
|
205
|
+
} 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
|
+
}
|