@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.
- 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 +48 -0
- package/dist/entity-resolver.d.ts.map +1 -0
- package/dist/entity-resolver.js +122 -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 +236 -0
- package/dist/entity-resolver.test.js.map +1 -0
- package/dist/index.d.ts +18 -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 +59 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +37 -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 +307 -0
- package/src/entity-resolver.ts +173 -0
- package/src/index.ts +79 -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 +86 -0
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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";
|