@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
@@ -8,8 +8,14 @@ import { computeSha256 } from "../utils/integrity.js";
8
8
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
9
9
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
10
10
  import { vaultApiFetch, getCompanyUid, getEntityUid, } from "../utils/vault-api.js";
11
+ import { HQ_API_KEY_PREFIX, assertCognitoOnlyCommand, resolveVaultCredential, } from "../utils/resolve-vault-credential.js";
11
12
  import { SandboxRunnerClient, } from "../utils/sandbox-runner-client.js";
12
13
  export { vaultApiFetch, getCompanyUid, getEntityUid };
14
+ /** Cognito session for secrets commands that do not support HQ_API_KEY. */
15
+ async function requireCognitoTokenForSecrets(commandLabel) {
16
+ assertCognitoOnlyCommand(commandLabel);
17
+ return ensureCognitoToken();
18
+ }
13
19
  function scopeOpts(opts) {
14
20
  if (opts.personal && opts.company) {
15
21
  console.error(chalk.red("Error: --personal cannot be combined with --company."));
@@ -424,7 +430,54 @@ function renderPolicyScripts(scripts) {
424
430
  // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
425
431
  // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
426
432
  // path used — never swallows a failure.
433
+ async function loadRevealedSecretsViaApiKey(token, keys) {
434
+ const resolved = new Map();
435
+ const requested = [...new Set(keys)];
436
+ const cacheScope = "__api_key__";
437
+ for (const name of requested) {
438
+ const res = await vaultApiFetch({
439
+ token,
440
+ path: "/v1/keys/secrets/fetch",
441
+ method: "POST",
442
+ body: { name },
443
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
444
+ });
445
+ const body = (await res.json().catch(() => ({})));
446
+ if (!res.ok) {
447
+ if (res.status === 404) {
448
+ throw new Error(`Failed to fetch secret '${name}': Secret not found`);
449
+ }
450
+ if (res.status === 403) {
451
+ const message = typeof body.error === "string"
452
+ ? body.error
453
+ : typeof body.message === "string"
454
+ ? body.message
455
+ : "No read permission";
456
+ if (body.highSecurity === true) {
457
+ throw new Error(highSecuritySandboxOnlyMessage(name));
458
+ }
459
+ throw new Error(`Failed to fetch secret '${name}': ${message}`);
460
+ }
461
+ if (res.status === 401) {
462
+ throw new Error(`Failed to fetch secret '${name}': Invalid or missing API key`);
463
+ }
464
+ throw new Error(`Failed to fetch secret '${name}': ${extractApiMessage(body, res.statusText)}`);
465
+ }
466
+ const secret = typeof body.secret === "object" && body.secret !== null
467
+ ? body.secret
468
+ : null;
469
+ if (typeof secret?.value !== "string") {
470
+ throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
471
+ }
472
+ removeCacheEntry(cacheScope, name);
473
+ resolved.set(name, secret.value);
474
+ }
475
+ return resolved;
476
+ }
427
477
  export async function loadRevealedSecrets(token, companyUid, keys, usage) {
478
+ if (token.startsWith(HQ_API_KEY_PREFIX)) {
479
+ return loadRevealedSecretsViaApiKey(token, keys);
480
+ }
428
481
  const resolved = new Map();
429
482
  const requested = [...new Set(keys)];
430
483
  try {
@@ -632,7 +685,7 @@ export function registerSecretsCommand(program) {
632
685
  console.error(chalk.red(`Secret value exceeds 4096-byte SSM limit (got ${Buffer.byteLength(value, "utf8")} bytes).`));
633
686
  process.exit(1);
634
687
  }
635
- const token = await ensureCognitoToken();
688
+ const token = await requireCognitoTokenForSecrets("secrets set");
636
689
  const scope = scopeOpts(secrets.opts());
637
690
  const companyUid = await getEntityUid(token, scope);
638
691
  const scopeLabel = describeSecretsScope({
@@ -677,7 +730,52 @@ export function registerSecretsCommand(program) {
677
730
  .option("--reveal", "Include the decrypted secret value")
678
731
  .action(async (name, opts) => {
679
732
  try {
680
- const token = await ensureCognitoToken();
733
+ const cred = await resolveVaultCredential();
734
+ if (cred.kind === "api-key") {
735
+ const res = await vaultApiFetch({
736
+ token: cred.token,
737
+ path: "/v1/keys/secrets/fetch",
738
+ method: "POST",
739
+ body: { name },
740
+ });
741
+ const body = (await res.json().catch(() => ({})));
742
+ if (!res.ok) {
743
+ if (res.status === 403 && body.highSecurity === true) {
744
+ console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
745
+ process.exit(1);
746
+ }
747
+ console.error(chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`));
748
+ process.exit(1);
749
+ }
750
+ const secret = typeof body.secret === "object" && body.secret !== null
751
+ ? body.secret
752
+ : null;
753
+ if (!secret || typeof secret.name !== "string") {
754
+ console.error(chalk.red("Failed to get secret: malformed response"));
755
+ process.exit(1);
756
+ }
757
+ console.log(chalk.bold(`Secret: ${secret.name}`));
758
+ if (secret.lastModifiedDate) {
759
+ console.log(` Last Modified: ${secret.lastModifiedDate}`);
760
+ }
761
+ if (secret.version != null) {
762
+ console.log(` Version: ${secret.version}`);
763
+ }
764
+ console.log(` Tier: ${normalizeSecretTier(secret.tier)}`);
765
+ console.log(` Script Lock: ${normalizeScriptLockMode(secret.scriptLock?.mode)}`);
766
+ if (opts.reveal) {
767
+ if (typeof secret.value !== "string") {
768
+ console.error(chalk.red("Failed to get secret: reveal requested but response omitted secret.value"));
769
+ process.exit(1);
770
+ }
771
+ console.log(` Value: ${secret.value}`);
772
+ }
773
+ else {
774
+ console.log(` Value: ${chalk.dim("[REDACTED]")}`);
775
+ }
776
+ return;
777
+ }
778
+ const token = cred.token;
681
779
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
682
780
  const res = await vaultApiFetch({
683
781
  token,
@@ -748,7 +846,7 @@ export function registerSecretsCommand(program) {
748
846
  .option("--quiet", "Suppress the present/absent line (use the exit code only)")
749
847
  .action(async (name, opts) => {
750
848
  try {
751
- const token = await ensureCognitoToken();
849
+ const token = await requireCognitoTokenForSecrets("secrets exists");
752
850
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
753
851
  const res = await vaultApiFetch({
754
852
  token,
@@ -791,7 +889,7 @@ export function registerSecretsCommand(program) {
791
889
  }
792
890
  normalizedPrefix = normalized;
793
891
  }
794
- const token = await ensureCognitoToken();
892
+ const token = await requireCognitoTokenForSecrets("secrets list");
795
893
  const scope = scopeOpts(secrets.opts());
796
894
  const companyUid = await getEntityUid(token, scope);
797
895
  const scopeLabel = describeSecretsScope({
@@ -865,7 +963,7 @@ export function registerSecretsCommand(program) {
865
963
  console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
866
964
  process.exit(1);
867
965
  }
868
- const token = await ensureCognitoToken();
966
+ const token = await requireCognitoTokenForSecrets("secrets");
869
967
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
870
968
  const res = await vaultApiFetch({
871
969
  token,
@@ -926,7 +1024,7 @@ export function registerSecretsCommand(program) {
926
1024
  console.error(chalk.red("Error: provide at least one of --tier or --lock-script."));
927
1025
  process.exit(1);
928
1026
  }
929
- const token = await ensureCognitoToken();
1027
+ const token = await requireCognitoTokenForSecrets("secrets");
930
1028
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
931
1029
  const res = await vaultApiFetch({
932
1030
  token,
@@ -971,7 +1069,7 @@ export function registerSecretsCommand(program) {
971
1069
  process.exit(1);
972
1070
  }
973
1071
  const usage = await buildSecretUsage("exec", opts.script, opts.id, opts.attestation);
974
- const token = await ensureCognitoToken();
1072
+ const token = await requireCognitoTokenForSecrets("secrets");
975
1073
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
976
1074
  const res = await vaultApiFetch({
977
1075
  token,
@@ -1009,7 +1107,7 @@ export function registerSecretsCommand(program) {
1009
1107
  console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
1010
1108
  process.exit(1);
1011
1109
  }
1012
- const token = await ensureCognitoToken();
1110
+ const token = await requireCognitoTokenForSecrets("secrets");
1013
1111
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1014
1112
  const res = await vaultApiFetch({
1015
1113
  token,
@@ -1040,7 +1138,7 @@ export function registerSecretsCommand(program) {
1040
1138
  console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
1041
1139
  process.exit(1);
1042
1140
  }
1043
- const token = await ensureCognitoToken();
1141
+ const token = await requireCognitoTokenForSecrets("secrets");
1044
1142
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1045
1143
  const res = await vaultApiFetch({
1046
1144
  token,
@@ -1087,7 +1185,7 @@ export function registerSecretsCommand(program) {
1087
1185
  return;
1088
1186
  }
1089
1187
  }
1090
- const token = await ensureCognitoToken();
1188
+ const token = await requireCognitoTokenForSecrets("secrets");
1091
1189
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1092
1190
  const res = await vaultApiFetch({
1093
1191
  token,
@@ -1131,7 +1229,7 @@ export function registerSecretsCommand(program) {
1131
1229
  process.exit(1);
1132
1230
  }
1133
1231
  const keys = parseSecretNameList(opts.only);
1134
- const token = await ensureCognitoToken();
1232
+ const token = await requireCognitoTokenForSecrets("secrets");
1135
1233
  const scope = scopeOpts(mergeScopeOpts(secrets.opts(), opts));
1136
1234
  const companyUid = await getEntityUid(token, scope);
1137
1235
  const client = new SandboxRunnerClient();
@@ -1184,9 +1282,13 @@ export function registerSecretsCommand(program) {
1184
1282
  process.exit(1);
1185
1283
  }
1186
1284
  const keys = parseSecretNameList(_opts.only);
1187
- const token = await ensureCognitoToken();
1188
- const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1189
- const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("exec", _opts.script, _opts.scriptId));
1285
+ const cred = await resolveVaultCredential();
1286
+ const companyUid = cred.kind === "api-key"
1287
+ ? "__api_key__"
1288
+ : await getEntityUid(cred.token, scopeOpts(secrets.opts()));
1289
+ const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
1290
+ ? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
1291
+ : undefined);
1190
1292
  const secretEnv = {};
1191
1293
  for (const key of keys) {
1192
1294
  const value = revealed.get(key);
@@ -1231,9 +1333,13 @@ export function registerSecretsCommand(program) {
1231
1333
  console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
1232
1334
  }
1233
1335
  const keys = parseSecretNameList(opts.only);
1234
- const token = await ensureCognitoToken();
1235
- const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1236
- const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("env", opts.script, opts.scriptId));
1336
+ const cred = await resolveVaultCredential();
1337
+ const companyUid = cred.kind === "api-key"
1338
+ ? "__api_key__"
1339
+ : await getEntityUid(cred.token, scopeOpts(secrets.opts()));
1340
+ const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
1341
+ ? await buildSecretUsage("env", opts.script, opts.scriptId)
1342
+ : undefined);
1237
1343
  for (const key of keys) {
1238
1344
  const value = revealed.get(key);
1239
1345
  // loadRevealedSecrets throws on any unresolved key, so a miss here is
@@ -1270,7 +1376,7 @@ export function registerSecretsCommand(program) {
1270
1376
  console.error(chalk.red("Maximum expiry is 7 days (7d)."));
1271
1377
  process.exit(1);
1272
1378
  }
1273
- const token = await ensureCognitoToken();
1379
+ const token = await requireCognitoTokenForSecrets("secrets");
1274
1380
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1275
1381
  const res = await vaultApiFetch({
1276
1382
  token,
@@ -1316,7 +1422,7 @@ export function registerSecretsCommand(program) {
1316
1422
  if (!principal) {
1317
1423
  process.exit(1);
1318
1424
  }
1319
- const token = await ensureCognitoToken();
1425
+ const token = await requireCognitoTokenForSecrets("secrets");
1320
1426
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1321
1427
  const res = await vaultApiFetch({
1322
1428
  token,
@@ -1373,7 +1479,7 @@ export function registerSecretsCommand(program) {
1373
1479
  if (!principal) {
1374
1480
  process.exit(1);
1375
1481
  }
1376
- const token = await ensureCognitoToken();
1482
+ const token = await requireCognitoTokenForSecrets("secrets");
1377
1483
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1378
1484
  const res = await vaultApiFetch({
1379
1485
  token,
@@ -1422,7 +1528,7 @@ export function registerSecretsCommand(program) {
1422
1528
  console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
1423
1529
  process.exit(1);
1424
1530
  }
1425
- const token = await ensureCognitoToken();
1531
+ const token = await requireCognitoTokenForSecrets("secrets");
1426
1532
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1427
1533
  const secretPath = path;
1428
1534
  const res = await vaultApiFetch({
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Outpost on-box realtime publisher — mission-control US-009.
3
+ *
4
+ * The box already holds a Cognito session (seeded from the caller's refresh
5
+ * token in user-data, kept fresh by the box's auth timers — see provision.ts).
6
+ * This module turns that session into a `PublishPort` for the session-heartbeat
7
+ * emitter using the EXACT on-box credential pattern the rest of the realtime
8
+ * fabric uses (docs/realtime-fabric.md):
9
+ *
10
+ * 1. POST {HQAPI}/v1/realtime/credentials with the box's Cognito JWT.
11
+ * The Lambda resolves the caller's `personUid` from the verified JWT
12
+ * (never request input) and vends short-lived STS creds whose session
13
+ * policy scopes `iot:Connect/Publish/...` to `hq/{personUid}/*` only.
14
+ * 2. SigV4-sign an IoT Data-plane publish with those creds to
15
+ * `hq/{personUid}/sessions`.
16
+ *
17
+ * No new auth surface, no embedded long-lived key, no per-device cert — the
18
+ * per-identity STS session policy is the isolation boundary (US-010).
19
+ *
20
+ * The HTTP fetch + IoT client are injected so this is unit-testable without a
21
+ * live endpoint; `defaultRealtimeCredentialsFetcher` and the IoT publish are
22
+ * the production wiring.
23
+ */
24
+ import { IoTDataPlaneClient } from "@aws-sdk/client-iot-data-plane";
25
+ import type { PublishPort } from "./session-heartbeat.js";
26
+ import { sessionsTopicForPerson } from "./session-heartbeat.js";
27
+ /** Shape returned by `POST /v1/realtime/credentials` (mirrors the handler). */
28
+ export interface RealtimeCredentialsResponse {
29
+ credentials: {
30
+ accessKeyId: string;
31
+ secretAccessKey: string;
32
+ sessionToken: string;
33
+ expiration: string;
34
+ };
35
+ iotEndpoint: string;
36
+ region: string;
37
+ /** The caller's own topic — `hq/{personUid}/...`. */
38
+ topic: string;
39
+ expiresAt: string;
40
+ }
41
+ /** Fetches scoped realtime credentials for the box. Injected for tests. */
42
+ export type RealtimeCredentialsFetcher = () => Promise<RealtimeCredentialsResponse>;
43
+ /** Ceiling on a single credentials request. */
44
+ export declare const DEFAULT_CREDENTIALS_TIMEOUT_MS = 10000;
45
+ /**
46
+ * Build the production credentials fetcher. Reads the box's current Cognito
47
+ * id/access token via the injected `getJwt` and POSTs it to the
48
+ * realtime-credentials endpoint.
49
+ */
50
+ export declare function defaultRealtimeCredentialsFetcher(opts: {
51
+ apiBaseUrl: string;
52
+ getJwt: () => Promise<string>;
53
+ fetchImpl?: typeof fetch;
54
+ /** Bound the request. Defaults to {@link DEFAULT_CREDENTIALS_TIMEOUT_MS}. */
55
+ timeoutMs?: number;
56
+ }): RealtimeCredentialsFetcher;
57
+ /**
58
+ * Create a `PublishPort` that vends scoped creds (refreshing before expiry) and
59
+ * publishes the compact payload to the box's own sessions topic over MQTT/IoT.
60
+ *
61
+ * @param fetchCredentials vends per-identity-scoped STS creds + IoT endpoint
62
+ * @param makeClient builds an IoT client from creds (injected for tests)
63
+ * @param now clock injection
64
+ */
65
+ export declare function createIotPublishPort(opts: {
66
+ fetchCredentials: RealtimeCredentialsFetcher;
67
+ makeClient?: (args: {
68
+ endpoint: string;
69
+ region: string;
70
+ credentials: RealtimeCredentialsResponse["credentials"];
71
+ }) => IoTDataPlaneClient;
72
+ now?: () => Date;
73
+ }): PublishPort;
74
+ /** Re-export for the runner so it imports one module. */
75
+ export { sessionsTopicForPerson };
76
+ //# sourceMappingURL=session-heartbeat-publisher.d.ts.map
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Outpost on-box realtime publisher — mission-control US-009.
3
+ *
4
+ * The box already holds a Cognito session (seeded from the caller's refresh
5
+ * token in user-data, kept fresh by the box's auth timers — see provision.ts).
6
+ * This module turns that session into a `PublishPort` for the session-heartbeat
7
+ * emitter using the EXACT on-box credential pattern the rest of the realtime
8
+ * fabric uses (docs/realtime-fabric.md):
9
+ *
10
+ * 1. POST {HQAPI}/v1/realtime/credentials with the box's Cognito JWT.
11
+ * The Lambda resolves the caller's `personUid` from the verified JWT
12
+ * (never request input) and vends short-lived STS creds whose session
13
+ * policy scopes `iot:Connect/Publish/...` to `hq/{personUid}/*` only.
14
+ * 2. SigV4-sign an IoT Data-plane publish with those creds to
15
+ * `hq/{personUid}/sessions`.
16
+ *
17
+ * No new auth surface, no embedded long-lived key, no per-device cert — the
18
+ * per-identity STS session policy is the isolation boundary (US-010).
19
+ *
20
+ * The HTTP fetch + IoT client are injected so this is unit-testable without a
21
+ * live endpoint; `defaultRealtimeCredentialsFetcher` and the IoT publish are
22
+ * the production wiring.
23
+ */
24
+ import { IoTDataPlaneClient, PublishCommand, } from "@aws-sdk/client-iot-data-plane";
25
+ import { assertNoSecretsInPayload, sessionsTopicForPerson, } from "./session-heartbeat.js";
26
+ /** Ceiling on a single credentials request. */
27
+ export const DEFAULT_CREDENTIALS_TIMEOUT_MS = 10_000;
28
+ /**
29
+ * Build the production credentials fetcher. Reads the box's current Cognito
30
+ * id/access token via the injected `getJwt` and POSTs it to the
31
+ * realtime-credentials endpoint.
32
+ */
33
+ export function defaultRealtimeCredentialsFetcher(opts) {
34
+ const doFetch = opts.fetchImpl ?? fetch;
35
+ return async () => {
36
+ const jwt = await opts.getJwt();
37
+ const res = await doFetch(`${opts.apiBaseUrl}/v1/realtime/credentials`, {
38
+ method: "POST",
39
+ // Bounded. An endpoint that accepts the connection but never answers
40
+ // would otherwise park the tick forever: the loop stops beating, the
41
+ // liveness marker goes stale, and SIGTERM cannot finish the in-flight
42
+ // tick — a hang that reads exactly like a healthy quiet box.
43
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_CREDENTIALS_TIMEOUT_MS),
44
+ headers: {
45
+ authorization: `Bearer ${jwt}`,
46
+ "content-type": "application/json",
47
+ },
48
+ });
49
+ if (!res.ok) {
50
+ throw new Error(`realtime/credentials returned ${res.status} ${res.statusText}`);
51
+ }
52
+ return (await res.json());
53
+ };
54
+ }
55
+ /**
56
+ * Create a `PublishPort` that vends scoped creds (refreshing before expiry) and
57
+ * publishes the compact payload to the box's own sessions topic over MQTT/IoT.
58
+ *
59
+ * @param fetchCredentials vends per-identity-scoped STS creds + IoT endpoint
60
+ * @param makeClient builds an IoT client from creds (injected for tests)
61
+ * @param now clock injection
62
+ */
63
+ export function createIotPublishPort(opts) {
64
+ const now = opts.now ?? (() => new Date());
65
+ const makeClient = opts.makeClient ??
66
+ (({ endpoint, region, credentials }) => {
67
+ const url = endpoint.startsWith("http")
68
+ ? endpoint
69
+ : `https://${endpoint}`;
70
+ return new IoTDataPlaneClient({
71
+ endpoint: url,
72
+ region,
73
+ credentials: {
74
+ accessKeyId: credentials.accessKeyId,
75
+ secretAccessKey: credentials.secretAccessKey,
76
+ sessionToken: credentials.sessionToken,
77
+ },
78
+ });
79
+ });
80
+ let cached = null;
81
+ // Refresh creds this many ms before they actually expire so a publish never
82
+ // races expiry mid-flight.
83
+ const REFRESH_SKEW_MS = 60_000;
84
+ async function clientFor() {
85
+ const nowMs = now().getTime();
86
+ if (cached && cached.expiresAtMs - REFRESH_SKEW_MS > nowMs) {
87
+ return { client: cached.client };
88
+ }
89
+ const vended = await opts.fetchCredentials();
90
+ const client = makeClient({
91
+ endpoint: vended.iotEndpoint,
92
+ region: vended.region,
93
+ credentials: vended.credentials,
94
+ });
95
+ cached = {
96
+ client,
97
+ endpoint: vended.iotEndpoint,
98
+ accessKeyId: vended.credentials.accessKeyId,
99
+ expiresAtMs: Date.parse(vended.credentials.expiration),
100
+ };
101
+ return { client };
102
+ }
103
+ return async (topic, payload) => {
104
+ // Re-guard at the transport boundary — a publisher must never ship a
105
+ // payload that fails the no-secrets contract, regardless of caller.
106
+ assertNoSecretsInPayload(payload);
107
+ const { client } = await clientFor();
108
+ await client.send(new PublishCommand({
109
+ topic,
110
+ qos: 0,
111
+ payload: Buffer.from(JSON.stringify(payload)),
112
+ }));
113
+ };
114
+ }
115
+ /** Re-export for the runner so it imports one module. */
116
+ export { sessionsTopicForPerson };
117
+ //# sourceMappingURL=session-heartbeat-publisher.js.map