@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.
- package/CHANGELOG.md +46 -0
- package/dist/commands/api-keys.js +53 -10
- package/dist/commands/outposts-heartbeat.d.ts +96 -0
- package/dist/commands/outposts-heartbeat.js +188 -0
- package/dist/commands/outposts.js +3 -0
- package/dist/commands/secrets.js +127 -21
- package/dist/outpost/session-heartbeat-publisher.d.ts +76 -0
- package/dist/outpost/session-heartbeat-publisher.js +117 -0
- package/dist/outpost/session-heartbeat.d.ts +210 -0
- package/dist/outpost/session-heartbeat.js +657 -0
- package/dist/utils/resolve-vault-credential.d.ts +30 -0
- package/dist/utils/resolve-vault-credential.js +48 -0
- package/dist/utils/vault-api.d.ts +8 -1
- package/dist/utils/vault-api.js +3 -2
- package/package.json +3 -1
- package/src/commands/api-keys.test.ts +75 -1
- package/src/commands/api-keys.ts +86 -10
- package/src/commands/outposts-heartbeat.test.ts +299 -0
- package/src/commands/outposts-heartbeat.ts +310 -0
- package/src/commands/outposts.ts +4 -0
- package/src/commands/secrets.test.ts +133 -0
- package/src/commands/secrets.ts +172 -29
- package/src/outpost/session-heartbeat-bounds.test.ts +195 -0
- package/src/outpost/session-heartbeat-guard.test.ts +105 -0
- package/src/outpost/session-heartbeat-publisher.test.ts +178 -0
- package/src/outpost/session-heartbeat-publisher.ts +186 -0
- package/src/outpost/session-heartbeat-retain-guard.test.ts +126 -0
- package/src/outpost/session-heartbeat.test.ts +459 -0
- package/src/outpost/session-heartbeat.ts +877 -0
- package/src/packaging.test.ts +45 -0
- package/src/utils/resolve-vault-credential.test.ts +69 -0
- package/src/utils/resolve-vault-credential.ts +60 -0
- package/src/utils/vault-api.ts +13 -2
package/src/packaging.test.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -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(
|
|
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");
|