@indigoai-us/hq-cli 5.77.11 → 5.77.13

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 (33) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/commands/api-keys.js +53 -10
  3. package/dist/commands/outposts-heartbeat.d.ts +96 -0
  4. package/dist/commands/outposts-heartbeat.js +188 -0
  5. package/dist/commands/outposts.js +3 -0
  6. package/dist/commands/secrets.js +127 -21
  7. package/dist/outpost/session-heartbeat-publisher.d.ts +76 -0
  8. package/dist/outpost/session-heartbeat-publisher.js +117 -0
  9. package/dist/outpost/session-heartbeat.d.ts +210 -0
  10. package/dist/outpost/session-heartbeat.js +657 -0
  11. package/dist/utils/resolve-vault-credential.d.ts +30 -0
  12. package/dist/utils/resolve-vault-credential.js +48 -0
  13. package/dist/utils/vault-api.d.ts +8 -1
  14. package/dist/utils/vault-api.js +3 -2
  15. package/package.json +3 -1
  16. package/src/commands/api-keys.test.ts +75 -1
  17. package/src/commands/api-keys.ts +86 -10
  18. package/src/commands/outposts-heartbeat.test.ts +299 -0
  19. package/src/commands/outposts-heartbeat.ts +310 -0
  20. package/src/commands/outposts.ts +4 -0
  21. package/src/commands/secrets.test.ts +133 -0
  22. package/src/commands/secrets.ts +172 -29
  23. package/src/outpost/session-heartbeat-bounds.test.ts +195 -0
  24. package/src/outpost/session-heartbeat-guard.test.ts +105 -0
  25. package/src/outpost/session-heartbeat-publisher.test.ts +178 -0
  26. package/src/outpost/session-heartbeat-publisher.ts +186 -0
  27. package/src/outpost/session-heartbeat-retain-guard.test.ts +126 -0
  28. package/src/outpost/session-heartbeat.test.ts +459 -0
  29. package/src/outpost/session-heartbeat.ts +877 -0
  30. package/src/packaging.test.ts +45 -0
  31. package/src/utils/resolve-vault-credential.test.ts +69 -0
  32. package/src/utils/resolve-vault-credential.ts +60 -0
  33. package/src/utils/vault-api.ts +13 -2
@@ -61,4 +61,49 @@ describe("packaging: runtime dependencies", () => {
61
61
  expect(pkg.dependencies).toHaveProperty("@aws-sdk/client-s3");
62
62
  expect(pkg.devDependencies).not.toHaveProperty("@aws-sdk/client-s3");
63
63
  });
64
+
65
+ it("ships the IoT data-plane client imported by the session heartbeat", () => {
66
+ expect(pkg.dependencies).toHaveProperty("@aws-sdk/client-iot-data-plane");
67
+ expect(pkg.devDependencies).not.toHaveProperty(
68
+ "@aws-sdk/client-iot-data-plane",
69
+ );
70
+ });
71
+ });
72
+
73
+ /**
74
+ * Regression pin for the 2026-07 dead-heartbeat incident.
75
+ *
76
+ * The Outpost's systemd unit invoked `outpost-session-heartbeat-runner`, a
77
+ * command that was never written. The provisioning test asserted only that the
78
+ * generated user-data CONTAINED that string, so it passed for the entire life
79
+ * of the bug while the box logged "command not found" every five seconds and
80
+ * the box audit reported the service healthy.
81
+ *
82
+ * The lesson: asserting that a command NAME appears somewhere proves nothing.
83
+ * Assert the command RESOLVES. If `hq outposts heartbeat` is ever renamed,
84
+ * un-registered, or dropped from the build, this fails here — before a box is
85
+ * provisioned against it.
86
+ */
87
+ describe("packaging: on-box commands the Outpost user-data invokes", () => {
88
+ it("registers `hq outposts heartbeat`", async () => {
89
+ const { Command } = await import("commander");
90
+ const { registerOutpostsCommand } = await import("./commands/outposts.js");
91
+
92
+ const program = new Command();
93
+ registerOutpostsCommand(program);
94
+
95
+ const outposts = program.commands.find((c) => c.name() === "outposts");
96
+ expect(outposts, "`hq outposts` must be registered").toBeDefined();
97
+
98
+ const heartbeat = outposts?.commands.find((c) => c.name() === "heartbeat");
99
+ expect(heartbeat, "`hq outposts heartbeat` must be registered").toBeDefined();
100
+
101
+ // The flags the systemd unit passes must all exist, or the unit dies on an
102
+ // "unknown option" at boot — a failure mode indistinguishable from the one
103
+ // this test exists to prevent.
104
+ const flags = (heartbeat?.options ?? []).map((o) => o.long);
105
+ expect(flags).toEqual(
106
+ expect.arrayContaining(["--once", "--interval", "--home", "--api-base-url"]),
107
+ );
108
+ });
64
109
  });
@@ -0,0 +1,69 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("./cognito-session.js", () => ({
4
+ ensureCognitoToken: vi.fn(async () => "cognito-token"),
5
+ }));
6
+
7
+ import { ensureCognitoToken } from "./cognito-session.js";
8
+ import {
9
+ assertCognitoOnlyCommand,
10
+ HQ_API_KEY_PREFIX,
11
+ peekHqApiKey,
12
+ resolveVaultCredential,
13
+ } from "./resolve-vault-credential.js";
14
+
15
+ afterEach(() => {
16
+ delete process.env.HQ_API_KEY;
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ describe("peekHqApiKey", () => {
21
+ it("returns undefined when unset or blank", () => {
22
+ expect(peekHqApiKey()).toBeUndefined();
23
+ process.env.HQ_API_KEY = " ";
24
+ expect(peekHqApiKey()).toBeUndefined();
25
+ });
26
+
27
+ it("returns trimmed value when set", () => {
28
+ process.env.HQ_API_KEY = " hqk_abc ";
29
+ expect(peekHqApiKey()).toBe("hqk_abc");
30
+ });
31
+ });
32
+
33
+ describe("resolveVaultCredential", () => {
34
+ it("uses Cognito when HQ_API_KEY is unset", async () => {
35
+ const cred = await resolveVaultCredential();
36
+ expect(cred).toEqual({ kind: "cognito", token: "cognito-token" });
37
+ expect(ensureCognitoToken).toHaveBeenCalledOnce();
38
+ });
39
+
40
+ it("returns api-key credential for hqk_ without calling Cognito", async () => {
41
+ process.env.HQ_API_KEY = `${HQ_API_KEY_PREFIX}valid`;
42
+ const cred = await resolveVaultCredential();
43
+ expect(cred).toEqual({
44
+ kind: "api-key",
45
+ token: `${HQ_API_KEY_PREFIX}valid`,
46
+ });
47
+ expect(ensureCognitoToken).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it("fails closed on invalid prefix (no Cognito fallback)", async () => {
51
+ process.env.HQ_API_KEY = "hqk_totally_invalid".replace("hqk_", "bad_");
52
+ process.env.HQ_API_KEY = "not_a_vault_key";
53
+ await expect(resolveVaultCredential()).rejects.toThrow(/must start with/);
54
+ expect(ensureCognitoToken).not.toHaveBeenCalled();
55
+ });
56
+ });
57
+
58
+ describe("assertCognitoOnlyCommand", () => {
59
+ it("no-ops when HQ_API_KEY unset", () => {
60
+ expect(() => assertCognitoOnlyCommand("secrets list")).not.toThrow();
61
+ });
62
+
63
+ it("throws when HQ_API_KEY is set", () => {
64
+ process.env.HQ_API_KEY = `${HQ_API_KEY_PREFIX}x`;
65
+ expect(() => assertCognitoOnlyCommand("secrets list")).toThrow(
66
+ /not supported for API keys/,
67
+ );
68
+ });
69
+ });
@@ -0,0 +1,60 @@
1
+ import { ensureCognitoToken } from "./cognito-session.js";
2
+
3
+ /** Vault API keys issued by `hq api-keys create` (hq-pro). */
4
+ export const HQ_API_KEY_PREFIX = "hqk_";
5
+
6
+ export type VaultCredential =
7
+ | { kind: "api-key"; token: string }
8
+ | { kind: "cognito"; token: string };
9
+
10
+ /**
11
+ * Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
12
+ * Does not validate prefix — use {@link resolveVaultCredential} for that.
13
+ */
14
+ export function peekHqApiKey(): string | undefined {
15
+ const raw = process.env.HQ_API_KEY;
16
+ if (raw === undefined) return undefined;
17
+ const trimmed = raw.trim();
18
+ return trimmed.length > 0 ? trimmed : undefined;
19
+ }
20
+
21
+ /**
22
+ * Resolve vault auth for CLI commands.
23
+ *
24
+ * When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
25
+ * and Cognito is never used as a fallback (fail-closed). When unset, uses the
26
+ * cached Cognito session (interactive login if needed).
27
+ */
28
+ export async function resolveVaultCredential(options?: {
29
+ interactive?: boolean;
30
+ }): Promise<VaultCredential> {
31
+ const apiKey = peekHqApiKey();
32
+ if (apiKey !== undefined) {
33
+ if (!apiKey.startsWith(HQ_API_KEY_PREFIX)) {
34
+ throw new Error(
35
+ `HQ_API_KEY must start with '${HQ_API_KEY_PREFIX}' (vault API key). ` +
36
+ `Got a value that is not a vault key — refusing to fall back to Cognito. ` +
37
+ `Unset HQ_API_KEY to use your session, or create a key with \`hq api-keys create\`.`,
38
+ );
39
+ }
40
+ return { kind: "api-key", token: apiKey };
41
+ }
42
+
43
+ const token = await ensureCognitoToken({
44
+ interactive: options?.interactive,
45
+ });
46
+ return { kind: "cognito", token };
47
+ }
48
+
49
+ /**
50
+ * Throw when HQ_API_KEY is set but the command only supports Cognito sessions
51
+ * (list, set, ACL, api-keys admin, etc.).
52
+ */
53
+ export function assertCognitoOnlyCommand(commandLabel: string): void {
54
+ if (peekHqApiKey() === undefined) return;
55
+ throw new Error(
56
+ `HQ_API_KEY is set; '${commandLabel}' is not supported for API keys. ` +
57
+ `API keys support scoped secret reads via \`hq secrets get\`, \`hq secrets exec\`, ` +
58
+ `and \`hq secrets env\`. Unset HQ_API_KEY to use your Cognito session.`,
59
+ );
60
+ }
@@ -5,6 +5,13 @@ import { CompanySelectionError } from './company-selection-error.js';
5
5
  import { recordPlanLimitStatus } from '../lib/plan-limit-nag.js';
6
6
 
7
7
  export interface VaultApiOptions {
8
+ /**
9
+ * Control-plane origin. Defaults to DEFAULT_VAULT_API_URL. Set it when a
10
+ * caller has been pointed at another deployment, so identity and every
11
+ * other call resolve against the SAME plane — a token minted for one and
12
+ * sent to another is simply rejected, which reads as an auth failure.
13
+ */
14
+ baseUrl?: string;
8
15
  token: string;
9
16
  path: string;
10
17
  method?: string;
@@ -72,7 +79,7 @@ async function peekPlanLimitStatus(response: Response): Promise<Response> {
72
79
  }
73
80
 
74
81
  export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
75
- const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
82
+ const url = new URL(opts.path, opts.baseUrl ?? DEFAULT_VAULT_API_URL);
76
83
  if (opts.query) {
77
84
  for (const [k, v] of Object.entries(opts.query)) {
78
85
  url.searchParams.set(k, v);
@@ -335,10 +342,14 @@ interface PersonEntity {
335
342
 
336
343
  // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
337
344
  // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
338
- export async function resolveCallerPersonUid(token: string): Promise<string> {
345
+ export async function resolveCallerPersonUid(
346
+ token: string,
347
+ baseUrl?: string,
348
+ ): Promise<string> {
339
349
  const res = await vaultApiFetch({
340
350
  token,
341
351
  path: '/entity/by-type/person',
352
+ baseUrl,
342
353
  });
343
354
  if (!res.ok) {
344
355
  throw new Error("Failed to fetch person entity — run `hq login` and try again");