@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/dist/commands/secrets.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1188
|
-
const companyUid =
|
|
1189
|
-
|
|
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
|
|
1235
|
-
const companyUid =
|
|
1236
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|