@indigoai-us/hq-cli 5.345.153 → 5.345.155
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 +88 -0
- package/dist/command-catalog.generated.d.ts +54 -6
- package/dist/command-catalog.generated.js +69 -6
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +92 -3
- package/dist/commands/cloud.d.ts +17 -0
- package/dist/commands/cloud.js +48 -1
- package/dist/commands/lanes.js +7 -1
- package/dist/commands/library-add.d.ts +6 -2
- package/dist/commands/library-add.js +45 -6
- package/dist/commands/library-caption.d.ts +38 -1
- package/dist/commands/library-caption.js +304 -3
- package/dist/commands/library-link.d.ts +7 -3
- package/dist/commands/library-link.js +10 -4
- package/dist/commands/library-remove.d.ts +122 -0
- package/dist/commands/library-remove.js +681 -0
- package/dist/commands/library.d.ts +26 -1
- package/dist/commands/library.js +101 -14
- package/dist/commands/secrets.js +72 -5
- package/dist/commands/storage-offload.d.ts +22 -4
- package/dist/commands/storage-offload.js +196 -2
- package/dist/commands/storage.js +5 -0
- package/dist/lib/daemon/machine-login.d.ts +20 -0
- package/dist/lib/daemon/machine-login.js +53 -8
- package/dist/lib/daemon/machine-token-identity.d.ts +34 -0
- package/dist/lib/daemon/machine-token-identity.js +123 -0
- package/dist/lib/lanes/registry-flag.d.ts +23 -2
- package/dist/lib/lanes/registry-flag.js +256 -36
- package/dist/lib/personal-secrets-audit.d.ts +31 -0
- package/dist/lib/personal-secrets-audit.js +45 -0
- package/dist/lib/sync/read-only-local-only.d.ts +45 -0
- package/dist/lib/sync/read-only-local-only.js +122 -0
- package/package.json +7 -7
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
* recorded in the sync journal as synced and cloud-only, then the
|
|
24
24
|
* placeholder is written, then the local file is removed. A path the
|
|
25
25
|
* sync ignore rules exclude is refused and the excluding rule is named
|
|
26
|
-
* (for `.hqinclude`, with the exact line to add).
|
|
26
|
+
* (for `.hqinclude`, with the exact line to add). A library original
|
|
27
|
+
* restored by `hq library fetch`, which the journal does not know, is
|
|
28
|
+
* pointed back at its verified blob `.hq-offload/blobs/<sha256>` in the
|
|
29
|
+
* company vault (`point-at-library`), uploading nothing. Other unsynced
|
|
27
30
|
* company files are skipped with "run hq sync first".
|
|
28
31
|
* - Any other file points at its personal-vault sync object the same
|
|
29
32
|
* way, or is uploaded to the personal vault's `.hq-offload/blobs/`.
|
|
@@ -71,6 +74,12 @@ export const RUN_SYNC_FIRST = "run hq sync first";
|
|
|
71
74
|
export const SYNC_MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
72
75
|
/** Object metadata key holding the sha256 of a company-vault offload upload. */
|
|
73
76
|
export const VAULT_SHA256_METADATA_KEY = "hq-sha256";
|
|
77
|
+
/**
|
|
78
|
+
* Object metadata key on library blobs (`hq library add`). hq-pro's put
|
|
79
|
+
* presign binds it to the `x-amz-checksum-sha256` it signs, and S3 checks that
|
|
80
|
+
* checksum against the bytes, so it is the stored bytes' sha256.
|
|
81
|
+
*/
|
|
82
|
+
export const CONTENT_SHA256_METADATA_KEY = "hq-content-sha256";
|
|
74
83
|
const DAY_MS = 86_400_000;
|
|
75
84
|
const PERSONAL_BUCKET_PREFIX = "hq-vault-prs-";
|
|
76
85
|
/** Top-level HQ-root folder that holds offloaded blobs in the personal vault. */
|
|
@@ -469,6 +478,9 @@ export function s3VaultObjectStore(bucket, s3, opts = {}) {
|
|
|
469
478
|
const sha = res.Metadata?.[VAULT_SHA256_METADATA_KEY];
|
|
470
479
|
if (sha && HEX64.test(sha))
|
|
471
480
|
head.sha256 = sha;
|
|
481
|
+
const content = res.Metadata?.[CONTENT_SHA256_METADATA_KEY]?.toLowerCase();
|
|
482
|
+
if (content && HEX64.test(content))
|
|
483
|
+
head.contentSha256 = content;
|
|
472
484
|
return head;
|
|
473
485
|
}
|
|
474
486
|
catch (err) {
|
|
@@ -731,6 +743,145 @@ async function pointCurrentFileAtVault(file, vault, deps, fsOps) {
|
|
|
731
743
|
}
|
|
732
744
|
await fsOps.removeOriginal(abs);
|
|
733
745
|
}
|
|
746
|
+
// ── Restored library originals (`hq library fetch`) ─────────────────────────
|
|
747
|
+
/** Library placeholders are small; anything bigger is not one. */
|
|
748
|
+
const MAX_LIBRARY_PLACEHOLDER_BYTES = 64 * 1024;
|
|
749
|
+
/**
|
|
750
|
+
* The library placeholder at `file`, when it is a blob-keyed company library
|
|
751
|
+
* copy (`hq library add`) for exactly `rel`. Anything else (a synced-path
|
|
752
|
+
* placeholder, a personal offload, another path, not a placeholder): null.
|
|
753
|
+
*/
|
|
754
|
+
function readLibraryPlaceholder(file, rel) {
|
|
755
|
+
let text;
|
|
756
|
+
try {
|
|
757
|
+
const st = fs.lstatSync(file);
|
|
758
|
+
if (!st.isFile() || st.size > MAX_LIBRARY_PLACEHOLDER_BYTES)
|
|
759
|
+
return null;
|
|
760
|
+
text = fs.readFileSync(file, "utf8");
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
return libraryPointerFor(text, rel);
|
|
766
|
+
}
|
|
767
|
+
function libraryPointerFor(text, rel) {
|
|
768
|
+
const pointer = parsePointer(text);
|
|
769
|
+
if (!pointer || !isCompanyOffloadCopy(pointer) || pointer.orphaned || pointer.path !== toPosix(rel))
|
|
770
|
+
return null;
|
|
771
|
+
return { pointer, text };
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Why a local file cannot be pointed at its library blob, or null when it
|
|
775
|
+
* can: the blob `.hq-offload/blobs/<sha256>` is in the company vault, its size
|
|
776
|
+
* matches, and its recorded sha256 (`hq-content-sha256`, or `hq-sha256`)
|
|
777
|
+
* equals the local file's. With `pointer` (the placeholder beside the file),
|
|
778
|
+
* the placeholder must also name these exact bytes.
|
|
779
|
+
*/
|
|
780
|
+
async function libraryBlobRefusal(pointer, digest, vault) {
|
|
781
|
+
if (pointer && (pointer.sha256 !== digest.sha256 || pointer.size !== digest.size)) {
|
|
782
|
+
return "This file differs from the library copy its placeholder points at, so it was kept.";
|
|
783
|
+
}
|
|
784
|
+
const key = companyOffloadKey(digest.sha256);
|
|
785
|
+
const head = await vault.head(key);
|
|
786
|
+
if (!head)
|
|
787
|
+
return `The library copy (${key}) is not in the company vault, so this file was kept.`;
|
|
788
|
+
const stored = head.contentSha256 ?? head.sha256;
|
|
789
|
+
if (head.size !== digest.size || (stored !== undefined && stored !== digest.sha256)) {
|
|
790
|
+
return `The library copy in the company vault (${key}) does not match this file, so it was kept.`;
|
|
791
|
+
}
|
|
792
|
+
if (stored === undefined) {
|
|
793
|
+
return `The library copy in the company vault (${key}) has no sha256 to check against, so this file was kept.`;
|
|
794
|
+
}
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
/** Whether `digest`'s verified library blob is in the company vault. A failed check counts as no. */
|
|
798
|
+
async function libraryBlobMatches(digest, vault) {
|
|
799
|
+
try {
|
|
800
|
+
return (await libraryBlobRefusal(null, digest, vault)) === null;
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Placeholder text for a restored library original that has none on disk:
|
|
808
|
+
* the company vault's own placeholder for this path when it is still there
|
|
809
|
+
* and names these exact bytes (so its `hq-asset` block, tags and caption are
|
|
810
|
+
* kept byte for byte), otherwise a plain library pointer to the blob.
|
|
811
|
+
*/
|
|
812
|
+
async function libraryPlaceholderText(rel, target, digest, vault, deps) {
|
|
813
|
+
const key = `${target.key}${PLACEHOLDER_SUFFIX}`;
|
|
814
|
+
const head = await vault.head(key).catch(() => null);
|
|
815
|
+
if (head && head.size > 0 && head.size <= MAX_LIBRARY_PLACEHOLDER_BYTES) {
|
|
816
|
+
const tmp = path.join(deps.tmpDir(), `hq-offload-placeholder-${crypto.randomBytes(6).toString("hex")}`);
|
|
817
|
+
try {
|
|
818
|
+
await vault.download(key, tmp, head.etag);
|
|
819
|
+
const found = libraryPointerFor(await fs.promises.readFile(tmp, "utf8"), rel);
|
|
820
|
+
if (found && found.pointer.sha256 === digest.sha256 && found.pointer.size === digest.size)
|
|
821
|
+
return found.text;
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
// Fall back to a plain pointer below.
|
|
825
|
+
}
|
|
826
|
+
finally {
|
|
827
|
+
await fs.promises.rm(tmp, { force: true });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return renderPointer({
|
|
831
|
+
path: toPosix(rel),
|
|
832
|
+
size: digest.size,
|
|
833
|
+
sha256: digest.sha256,
|
|
834
|
+
vault: "company",
|
|
835
|
+
key: companyOffloadKey(digest.sha256),
|
|
836
|
+
offloadedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Turn a restored library original back into a placeholder. Its bytes are
|
|
841
|
+
* already in the company vault at `.hq-offload/blobs/<sha256>`, so nothing is
|
|
842
|
+
* uploaded. Right before the local file is removed, the blob is checked again
|
|
843
|
+
* and the local file is hashed again; both must still match. With the library
|
|
844
|
+
* placeholder beside the file, that placeholder is left exactly as it is;
|
|
845
|
+
* otherwise one is written (fsynced) first.
|
|
846
|
+
*/
|
|
847
|
+
async function pointCurrentFileAtLibrary(file, vault, deps, fsOps) {
|
|
848
|
+
const { plan, target, stat: before, digest, besideText } = file;
|
|
849
|
+
if (target.vault !== "company" || !before || !digest)
|
|
850
|
+
throw new Error(`'${plan.path}' is not a company file.`);
|
|
851
|
+
const abs = containedPath(deps.hqRoot, plan.path);
|
|
852
|
+
const placeholder = `${abs}${PLACEHOLDER_SUFFIX}`;
|
|
853
|
+
const beside = besideText === undefined ? null : libraryPointerFor(besideText, plan.path);
|
|
854
|
+
if (besideText !== undefined && !beside)
|
|
855
|
+
throw new Error(`'${plan.path}${PLACEHOLDER_SUFFIX}' is not a library placeholder.`);
|
|
856
|
+
const refusal = await libraryBlobRefusal(beside?.pointer ?? null, digest, vault);
|
|
857
|
+
if (refusal)
|
|
858
|
+
throw new Error(refusal);
|
|
859
|
+
const changed = new Error(`'${plan.path}' changed while offloading. Nothing was replaced.`);
|
|
860
|
+
const unchanged = async () => {
|
|
861
|
+
const st = await fs.promises.stat(abs);
|
|
862
|
+
return st.size === before.size && st.mtimeMs === before.mtimeMs && st.size === digest.size;
|
|
863
|
+
};
|
|
864
|
+
if (!(await unchanged()))
|
|
865
|
+
throw changed;
|
|
866
|
+
let wrote = false;
|
|
867
|
+
if (beside) {
|
|
868
|
+
const current = await fs.promises.readFile(placeholder, "utf8").catch(() => null);
|
|
869
|
+
if (current !== besideText) {
|
|
870
|
+
throw new Error(`'${plan.path}${PLACEHOLDER_SUFFIX}' changed while offloading. Nothing was replaced.`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
await fsOps.writePlaceholder(placeholder, await libraryPlaceholderText(plan.path, target, digest, vault, deps));
|
|
875
|
+
wrote = true;
|
|
876
|
+
}
|
|
877
|
+
const again = await digestFile(abs);
|
|
878
|
+
if (again.sha256 !== digest.sha256 || again.size !== digest.size || !(await unchanged())) {
|
|
879
|
+
if (wrote)
|
|
880
|
+
await fs.promises.rm(placeholder, { force: true });
|
|
881
|
+
throw changed;
|
|
882
|
+
}
|
|
883
|
+
await fsOps.removeOriginal(abs);
|
|
884
|
+
}
|
|
734
885
|
/** Resumable upload state for one company vault key. */
|
|
735
886
|
function uploadStatePath(deps, target) {
|
|
736
887
|
const dir = deps.uploadStateDir ? deps.uploadStateDir() : path.join(os.homedir(), ".hq", "offload-uploads");
|
|
@@ -914,8 +1065,17 @@ async function planCurrentFiles(rels, opts, deps, openVault) {
|
|
|
914
1065
|
continue;
|
|
915
1066
|
throw err;
|
|
916
1067
|
}
|
|
917
|
-
if (!st.isFile()
|
|
1068
|
+
if (!st.isFile())
|
|
918
1069
|
continue;
|
|
1070
|
+
// A file with a placeholder beside it is left alone, except a restored
|
|
1071
|
+
// library original next to its own blob-keyed library placeholder (what
|
|
1072
|
+
// `hq library fetch` leaves once HQ sync restores the placeholder).
|
|
1073
|
+
let beside;
|
|
1074
|
+
if (fs.existsSync(`${abs}${PLACEHOLDER_SUFFIX}`)) {
|
|
1075
|
+
beside = readLibraryPlaceholder(`${abs}${PLACEHOLDER_SUFFIX}`, rel) ?? undefined;
|
|
1076
|
+
if (!beside)
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
919
1079
|
const idle = Math.max(0, Math.floor((now - st.mtimeMs) / DAY_MS));
|
|
920
1080
|
if (st.size < opts.minBytes || idle < opts.idleDays)
|
|
921
1081
|
continue;
|
|
@@ -938,6 +1098,23 @@ async function planCurrentFiles(rels, opts, deps, openVault) {
|
|
|
938
1098
|
}
|
|
939
1099
|
}
|
|
940
1100
|
const record = deps.syncRecord ? await deps.syncRecord(target) : null;
|
|
1101
|
+
if (beside) {
|
|
1102
|
+
// A file the sync journal knows keeps the old behaviour: left alone.
|
|
1103
|
+
if (record || target.vault !== "company" || !deps.openVaultObjects)
|
|
1104
|
+
continue;
|
|
1105
|
+
const besideDigest = await digestFile(abs);
|
|
1106
|
+
plan.vault_key = beside.pointer.key;
|
|
1107
|
+
const refusal = await libraryBlobRefusal(beside.pointer, besideDigest, await openVault(target)).catch((err) => `Could not check the library copy in the company vault (${failureMessage(err)}), so this file was kept.`);
|
|
1108
|
+
if (refusal) {
|
|
1109
|
+
plan.reason = refusal;
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
plan.action = "point-at-library";
|
|
1113
|
+
plan.placeholder = "kept";
|
|
1114
|
+
}
|
|
1115
|
+
planned.push({ plan, target, stat: st, digest: besideDigest, besideText: beside.text });
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
941
1118
|
let digest;
|
|
942
1119
|
if (record?.remoteEtag && record.hash && record.size === st.size) {
|
|
943
1120
|
digest = await digestFile(abs);
|
|
@@ -946,9 +1123,22 @@ async function planCurrentFiles(rels, opts, deps, openVault) {
|
|
|
946
1123
|
plan.synced = head !== null && head.etag === normalizeEtag(record.remoteEtag) && head.size === st.size;
|
|
947
1124
|
}
|
|
948
1125
|
}
|
|
1126
|
+
let libraryBlob = false;
|
|
1127
|
+
if (!plan.synced && !record && target.vault === "company" && deps.openVaultObjects) {
|
|
1128
|
+
// Not in the sync journal at all, e.g. a library original restored by
|
|
1129
|
+
// `hq library fetch` after HQ sync dropped its placeholder. Its bytes
|
|
1130
|
+
// may already be in the company vault at their content-addressed key.
|
|
1131
|
+
digest ??= await digestFile(abs);
|
|
1132
|
+
libraryBlob = await libraryBlobMatches(digest, await openVault(target));
|
|
1133
|
+
}
|
|
949
1134
|
if (plan.synced) {
|
|
950
1135
|
plan.action = "point-at-vault";
|
|
951
1136
|
}
|
|
1137
|
+
else if (libraryBlob && digest) {
|
|
1138
|
+
plan.action = "point-at-library";
|
|
1139
|
+
plan.vault_key = companyOffloadKey(digest.sha256);
|
|
1140
|
+
plan.placeholder = "written";
|
|
1141
|
+
}
|
|
952
1142
|
else if (target.vault === "company") {
|
|
953
1143
|
const ex = deps.syncExclusion?.(rel);
|
|
954
1144
|
if (ex?.excluded) {
|
|
@@ -1046,6 +1236,10 @@ async function runCurrent(opts, deps, result) {
|
|
|
1046
1236
|
await pointCurrentFileAtVault(file, await openVault(target), deps, fsOps);
|
|
1047
1237
|
action = "point-at-vault";
|
|
1048
1238
|
}
|
|
1239
|
+
else if (plan.action === "point-at-library") {
|
|
1240
|
+
await pointCurrentFileAtLibrary(file, await openVault(target), deps, fsOps);
|
|
1241
|
+
action = "point-at-library";
|
|
1242
|
+
}
|
|
1049
1243
|
else if (plan.action === "upload+offload") {
|
|
1050
1244
|
if (target.vault !== "company" || !deps.openCompanyUploader)
|
|
1051
1245
|
throw new Error(UPDATE_SYNC);
|
package/dist/commands/storage.js
CHANGED
|
@@ -839,6 +839,7 @@ const CURRENT_ACTION_LABEL = {
|
|
|
839
839
|
"point-at-vault": "point at vault",
|
|
840
840
|
upload: "upload",
|
|
841
841
|
"upload+offload": "upload+offload",
|
|
842
|
+
"point-at-library": "point at library copy",
|
|
842
843
|
skip: "skip",
|
|
843
844
|
};
|
|
844
845
|
/** One `--current` plan line: path, size, owning vault, sync state, action. */
|
|
@@ -848,6 +849,10 @@ function formatCurrentFile(f) {
|
|
|
848
849
|
if (f.action === "upload+offload") {
|
|
849
850
|
parts.push(`(${f.bytes} bytes to the company ${f.company ?? "?"} vault at ${f.vault_key ?? f.path})`);
|
|
850
851
|
}
|
|
852
|
+
if (f.action === "point-at-library") {
|
|
853
|
+
const placeholder = f.placeholder === "kept" ? "placeholder kept as is" : "placeholder written";
|
|
854
|
+
parts.push(`(already in the company ${f.company ?? "?"} vault at ${f.vault_key ?? "?"}, nothing uploaded, ${placeholder})`);
|
|
855
|
+
}
|
|
851
856
|
if (f.action === "skip" && f.reason)
|
|
852
857
|
parts.push(`(${f.reason})`);
|
|
853
858
|
return parts.join(" ");
|
|
@@ -17,4 +17,24 @@ export interface MachineAuthenticatorOptions {
|
|
|
17
17
|
export declare function machineTokenExpiresAt(token: string): number;
|
|
18
18
|
/** Authenticate without using hq-cloud's persistent machine token cache. */
|
|
19
19
|
export declare function authenticateMachineCredentials(credentials: StdinMachineCredentials, options?: MachineAuthenticatorOptions): Promise<string>;
|
|
20
|
+
export interface MachineSessionTokens {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
idToken: string;
|
|
23
|
+
/** Present only when Cognito returned one for this app client. */
|
|
24
|
+
refreshToken?: string;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Mint a full machine session in memory. Unlike hq-cloud's
|
|
29
|
+
* getValidMachineTokens, this writes no token cache and takes no lock file,
|
|
30
|
+
* and it keeps the refresh token Cognito returns (hq-cloud discards it).
|
|
31
|
+
* The client is the creds file's clientId, else HQ_COGNITO_CLIENT_ID, else
|
|
32
|
+
* DEFAULT_COGNITO.clientId, the same order hq-cloud uses for a machine mint.
|
|
33
|
+
*
|
|
34
|
+
* Before returning, the token pair must pass the same identity check hq-cloud
|
|
35
|
+
* runs on every machine mint (client_id/aud, username/sub, entity type and
|
|
36
|
+
* uid, and no company markers on a non-company identity). A mismatch throws
|
|
37
|
+
* `identity_mismatch` and no token leaves this function.
|
|
38
|
+
*/
|
|
39
|
+
export declare function authenticateMachineSession(credentials: StdinMachineCredentials, options?: MachineAuthenticatorOptions): Promise<MachineSessionTokens>;
|
|
20
40
|
//# sourceMappingURL=machine-login.d.ts.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Ephemeral Cognito machine login for a resident hqd process. */
|
|
2
2
|
import { DEFAULT_COGNITO } from "../../utils/cognito-session.js";
|
|
3
|
+
import { machineTokenPairMatchesIdentity } from "./machine-token-identity.js";
|
|
3
4
|
const MAX_STDIN_BYTES = 64 * 1024;
|
|
4
5
|
export class MachineLoginError extends Error {
|
|
5
6
|
code;
|
|
@@ -57,11 +58,16 @@ export function machineTokenExpiresAt(token) {
|
|
|
57
58
|
throw new MachineLoginError("auth_failed", "hqd sign-in failed; the identity provider returned an invalid token");
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const DEFAULT_AUTH_FAILURE = "hqd sign-in failed; check the machine credentials and try again";
|
|
62
|
+
/** Client resolution order shared with hq-cloud: creds, then env, then default. */
|
|
63
|
+
function resolveMachineClientId(credentials, env) {
|
|
64
|
+
return credentials.clientId ?? env.HQ_COGNITO_CLIENT_ID ?? DEFAULT_COGNITO.clientId;
|
|
65
|
+
}
|
|
66
|
+
/** Run USER_PASSWORD_AUTH and return the AuthenticationResult object. Never persists anything. */
|
|
67
|
+
async function initiateMachineAuth(credentials, options, failureMessage) {
|
|
62
68
|
const env = options.env ?? process.env;
|
|
63
69
|
const region = credentials.region ?? env.AWS_REGION ?? DEFAULT_COGNITO.region;
|
|
64
|
-
const clientId = credentials
|
|
70
|
+
const clientId = resolveMachineClientId(credentials, env);
|
|
65
71
|
const endpoint = options.endpoint ?? `https://cognito-idp.${region}.amazonaws.com/`;
|
|
66
72
|
let response;
|
|
67
73
|
try {
|
|
@@ -80,23 +86,62 @@ export async function authenticateMachineCredentials(credentials, options = {})
|
|
|
80
86
|
});
|
|
81
87
|
}
|
|
82
88
|
catch {
|
|
83
|
-
throw new MachineLoginError("auth_failed",
|
|
89
|
+
throw new MachineLoginError("auth_failed", failureMessage);
|
|
84
90
|
}
|
|
85
91
|
let body;
|
|
86
92
|
try {
|
|
87
93
|
body = await response.json();
|
|
88
94
|
}
|
|
89
95
|
catch {
|
|
90
|
-
throw new MachineLoginError("auth_failed",
|
|
96
|
+
throw new MachineLoginError("auth_failed", failureMessage);
|
|
91
97
|
}
|
|
92
98
|
if (!response.ok || !body || typeof body !== "object" || Array.isArray(body)) {
|
|
93
|
-
throw new MachineLoginError("auth_failed",
|
|
99
|
+
throw new MachineLoginError("auth_failed", failureMessage);
|
|
94
100
|
}
|
|
95
101
|
const result = body.AuthenticationResult;
|
|
96
|
-
|
|
102
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
103
|
+
throw new MachineLoginError("auth_failed", failureMessage);
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
/** Authenticate without using hq-cloud's persistent machine token cache. */
|
|
108
|
+
export async function authenticateMachineCredentials(credentials, options = {}) {
|
|
109
|
+
const result = await initiateMachineAuth(credentials, options, DEFAULT_AUTH_FAILURE);
|
|
110
|
+
const idToken = result.IdToken;
|
|
97
111
|
if (typeof idToken !== "string" || !idToken) {
|
|
98
|
-
throw new MachineLoginError("auth_failed",
|
|
112
|
+
throw new MachineLoginError("auth_failed", DEFAULT_AUTH_FAILURE);
|
|
99
113
|
}
|
|
100
114
|
return idToken;
|
|
101
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Mint a full machine session in memory. Unlike hq-cloud's
|
|
118
|
+
* getValidMachineTokens, this writes no token cache and takes no lock file,
|
|
119
|
+
* and it keeps the refresh token Cognito returns (hq-cloud discards it).
|
|
120
|
+
* The client is the creds file's clientId, else HQ_COGNITO_CLIENT_ID, else
|
|
121
|
+
* DEFAULT_COGNITO.clientId, the same order hq-cloud uses for a machine mint.
|
|
122
|
+
*
|
|
123
|
+
* Before returning, the token pair must pass the same identity check hq-cloud
|
|
124
|
+
* runs on every machine mint (client_id/aud, username/sub, entity type and
|
|
125
|
+
* uid, and no company markers on a non-company identity). A mismatch throws
|
|
126
|
+
* `identity_mismatch` and no token leaves this function.
|
|
127
|
+
*/
|
|
128
|
+
export async function authenticateMachineSession(credentials, options = {}) {
|
|
129
|
+
const failure = "Machine sign-in failed; check the machine credentials and try again";
|
|
130
|
+
const result = await initiateMachineAuth(credentials, options, failure);
|
|
131
|
+
const { AccessToken, IdToken, RefreshToken, ExpiresIn } = result;
|
|
132
|
+
if (typeof AccessToken !== "string" || !AccessToken || typeof IdToken !== "string" || !IdToken) {
|
|
133
|
+
throw new MachineLoginError("auth_failed", failure);
|
|
134
|
+
}
|
|
135
|
+
const clientId = resolveMachineClientId(credentials, options.env ?? process.env);
|
|
136
|
+
if (!machineTokenPairMatchesIdentity({ accessToken: AccessToken, idToken: IdToken }, credentials, clientId)) {
|
|
137
|
+
throw new MachineLoginError("identity_mismatch", "Machine sign-in returned tokens that do not match the requested machine identity");
|
|
138
|
+
}
|
|
139
|
+
const lifetimeSeconds = typeof ExpiresIn === "number" && Number.isFinite(ExpiresIn) ? ExpiresIn : 3600;
|
|
140
|
+
return {
|
|
141
|
+
accessToken: AccessToken,
|
|
142
|
+
idToken: IdToken,
|
|
143
|
+
...(typeof RefreshToken === "string" && RefreshToken ? { refreshToken: RefreshToken } : {}),
|
|
144
|
+
expiresAt: Date.now() + lifetimeSeconds * 1000,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
102
147
|
//# sourceMappingURL=machine-login.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Faithful port of hq-cloud's private `machineTokenPairMatchesIdentity` and its
|
|
3
|
+
* helpers (`decodeJwtClaims`, `hasCompanyMarkers`, `companyClaimsMatch`).
|
|
4
|
+
*
|
|
5
|
+
* Source: @indigoai-us/hq-cloud v6.18.91, dist/cognito-auth.js (search for
|
|
6
|
+
* `function machineTokenPairMatchesIdentity`). hq-cloud does not export it in
|
|
7
|
+
* that version. Keep this file in step with that function; once hq-cloud
|
|
8
|
+
* exports it, import the export and delete this port.
|
|
9
|
+
*
|
|
10
|
+
* Claims are decoded without verifying the signature. That matches hq-cloud:
|
|
11
|
+
* the tokens come straight from Cognito over TLS, and the check exists to bind
|
|
12
|
+
* them to the requested machine identity and to refuse company-scoped tokens
|
|
13
|
+
* on a non-company identity, not to authenticate the issuer.
|
|
14
|
+
*/
|
|
15
|
+
import type { MachineCreds } from "@indigoai-us/hq-cloud";
|
|
16
|
+
type Claims = Record<string, unknown>;
|
|
17
|
+
export interface MachineTokenPair {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
idToken: string;
|
|
20
|
+
}
|
|
21
|
+
export type MachineIdentityCreds = Pick<MachineCreds, "username"> & Partial<Pick<MachineCreds, "entityType" | "entityUid">>;
|
|
22
|
+
export declare function decodeJwtClaims(token: string): Claims | null;
|
|
23
|
+
/** True when a token carries any company-outpost marker claim. */
|
|
24
|
+
export declare function hasCompanyMarkers(claims: Claims): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* True only when the access/ID token pair belongs to `creds` on
|
|
27
|
+
* `expectedClientId`: access `client_id` and ID `aud` equal the client, the
|
|
28
|
+
* entity type and uid match (agt_* agent, or out_* outpost with delegated
|
|
29
|
+
* claims), the ID token binds to the creds username or entityUid, both tokens
|
|
30
|
+
* share a subject, and a non-company identity carries no company markers.
|
|
31
|
+
*/
|
|
32
|
+
export declare function machineTokenPairMatchesIdentity(tokens: MachineTokenPair, creds: MachineIdentityCreds, expectedClientId: string): boolean;
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=machine-token-identity.d.ts.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Faithful port of hq-cloud's private `machineTokenPairMatchesIdentity` and its
|
|
3
|
+
* helpers (`decodeJwtClaims`, `hasCompanyMarkers`, `companyClaimsMatch`).
|
|
4
|
+
*
|
|
5
|
+
* Source: @indigoai-us/hq-cloud v6.18.91, dist/cognito-auth.js (search for
|
|
6
|
+
* `function machineTokenPairMatchesIdentity`). hq-cloud does not export it in
|
|
7
|
+
* that version. Keep this file in step with that function; once hq-cloud
|
|
8
|
+
* exports it, import the export and delete this port.
|
|
9
|
+
*
|
|
10
|
+
* Claims are decoded without verifying the signature. That matches hq-cloud:
|
|
11
|
+
* the tokens come straight from Cognito over TLS, and the check exists to bind
|
|
12
|
+
* them to the requested machine identity and to refuse company-scoped tokens
|
|
13
|
+
* on a non-company identity, not to authenticate the issuer.
|
|
14
|
+
*/
|
|
15
|
+
export function decodeJwtClaims(token) {
|
|
16
|
+
try {
|
|
17
|
+
const parts = token.split(".");
|
|
18
|
+
if (parts.length < 2)
|
|
19
|
+
return null;
|
|
20
|
+
const payloadB64 = parts[1];
|
|
21
|
+
const normalized = payloadB64.replace(/-/g, "+").replace(/_/g, "/");
|
|
22
|
+
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
23
|
+
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
24
|
+
const claims = JSON.parse(json);
|
|
25
|
+
return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : null;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const owns = (bag, key) => Object.prototype.hasOwnProperty.call(bag, key);
|
|
32
|
+
/** True when a token carries any company-outpost marker claim. */
|
|
33
|
+
export function hasCompanyMarkers(claims) {
|
|
34
|
+
return (claims["custom:entityType"] === "company-outpost" ||
|
|
35
|
+
owns(claims, "custom:ownerCompanyUid") ||
|
|
36
|
+
owns(claims, "custom:companyOutpostId"));
|
|
37
|
+
}
|
|
38
|
+
function companyClaimsMatch(claims, creds, clientId) {
|
|
39
|
+
const company = claims["custom:ownerCompanyUid"];
|
|
40
|
+
return (claims["custom:entityType"] === "company-outpost" &&
|
|
41
|
+
claims["custom:entityUid"] === creds.entityUid &&
|
|
42
|
+
claims["custom:authClientId"] === clientId &&
|
|
43
|
+
typeof company === "string" &&
|
|
44
|
+
/^cmp_[A-Za-z0-9]+$/.test(company) &&
|
|
45
|
+
claims["custom:companyOutpostId"] === `outpost_${company}` &&
|
|
46
|
+
(!process.env.HQ_COMPANY_UID || company === process.env.HQ_COMPANY_UID) &&
|
|
47
|
+
(!process.env.HQ_COMPANY_OUTPOST_ID || claims["custom:companyOutpostId"] === process.env.HQ_COMPANY_OUTPOST_ID) &&
|
|
48
|
+
!owns(claims, "custom:delegatedSub") &&
|
|
49
|
+
!owns(claims, "custom:delegatedEmail") &&
|
|
50
|
+
typeof claims.sub === "string" &&
|
|
51
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(claims.sub) &&
|
|
52
|
+
typeof claims.exp === "number" &&
|
|
53
|
+
claims.exp * 1000 > Date.now());
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* True only when the access/ID token pair belongs to `creds` on
|
|
57
|
+
* `expectedClientId`: access `client_id` and ID `aud` equal the client, the
|
|
58
|
+
* entity type and uid match (agt_* agent, or out_* outpost with delegated
|
|
59
|
+
* claims), the ID token binds to the creds username or entityUid, both tokens
|
|
60
|
+
* share a subject, and a non-company identity carries no company markers.
|
|
61
|
+
*/
|
|
62
|
+
export function machineTokenPairMatchesIdentity(tokens, creds, expectedClientId) {
|
|
63
|
+
const accessClaims = decodeJwtClaims(tokens.accessToken);
|
|
64
|
+
const idClaims = decodeJwtClaims(tokens.idToken);
|
|
65
|
+
if (!accessClaims || !idClaims)
|
|
66
|
+
return false;
|
|
67
|
+
if (creds.entityType === "company-outpost") {
|
|
68
|
+
if (!creds.entityUid ||
|
|
69
|
+
!/^out_[0-9A-HJKMNP-TV-Z]{26}$/.test(creds.entityUid) ||
|
|
70
|
+
!companyClaimsMatch(idClaims, creds, expectedClientId) ||
|
|
71
|
+
!companyClaimsMatch(accessClaims, creds, expectedClientId) ||
|
|
72
|
+
idClaims["custom:ownerCompanyUid"] !== accessClaims["custom:ownerCompanyUid"])
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
else if (hasCompanyMarkers(idClaims) || hasCompanyMarkers(accessClaims))
|
|
76
|
+
return false;
|
|
77
|
+
const stringClaim = (claims, key) => {
|
|
78
|
+
const value = claims[key];
|
|
79
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
80
|
+
};
|
|
81
|
+
const accessClientId = accessClaims.client_id;
|
|
82
|
+
const idAudience = idClaims.aud;
|
|
83
|
+
const accessUsername = stringClaim(accessClaims, "username") ?? stringClaim(accessClaims, "cognito:username");
|
|
84
|
+
const accessSub = stringClaim(accessClaims, "sub");
|
|
85
|
+
const idUsername = stringClaim(idClaims, "cognito:username") ?? stringClaim(idClaims, "username");
|
|
86
|
+
const idSub = stringClaim(idClaims, "sub");
|
|
87
|
+
const entityType = stringClaim(idClaims, "custom:entityType");
|
|
88
|
+
const entityUid = stringClaim(idClaims, "custom:entityUid");
|
|
89
|
+
const delegatedSub = stringClaim(idClaims, "custom:delegatedSub");
|
|
90
|
+
const delegatedEmail = stringClaim(idClaims, "custom:delegatedEmail");
|
|
91
|
+
const hasValidMachineClaimShape = entityType === "company-outpost" ||
|
|
92
|
+
(entityType === "agent" && entityUid !== null && entityUid.startsWith("agt_")) ||
|
|
93
|
+
(entityType === "outpost" &&
|
|
94
|
+
entityUid !== null &&
|
|
95
|
+
entityUid.startsWith("out_") &&
|
|
96
|
+
delegatedSub !== null &&
|
|
97
|
+
delegatedEmail !== null);
|
|
98
|
+
// Legacy agent credentials did not persist entityType; "agent" is the only
|
|
99
|
+
// valid inference (same as hq-cloud).
|
|
100
|
+
const expectedEntityType = creds.entityType ?? "agent";
|
|
101
|
+
const entityMatchesCreds = entityType === expectedEntityType && (creds.entityUid === undefined || entityUid === creds.entityUid);
|
|
102
|
+
// In the email-alias pool the canonical username is a UUID, so creds that
|
|
103
|
+
// persist entityUid bind through the unique custom:entityUid claim; legacy
|
|
104
|
+
// creds without entityUid keep the username binding (same as hq-cloud).
|
|
105
|
+
const idTokenMatchesCreds = idUsername === creds.username ||
|
|
106
|
+
idSub === creds.username ||
|
|
107
|
+
(creds.entityUid !== undefined && entityUid === creds.entityUid);
|
|
108
|
+
const subjectBindings = [];
|
|
109
|
+
if (accessUsername !== null && idUsername !== null)
|
|
110
|
+
subjectBindings.push(accessUsername === idUsername);
|
|
111
|
+
if (accessSub !== null && idSub !== null)
|
|
112
|
+
subjectBindings.push(accessSub === idSub);
|
|
113
|
+
const tokensShareSubject = subjectBindings.length > 0 && subjectBindings.every(Boolean);
|
|
114
|
+
return (accessClaims.token_use === "access" &&
|
|
115
|
+
accessClientId === expectedClientId &&
|
|
116
|
+
idClaims.token_use === "id" &&
|
|
117
|
+
idAudience === expectedClientId &&
|
|
118
|
+
hasValidMachineClaimShape &&
|
|
119
|
+
entityMatchesCreds &&
|
|
120
|
+
idTokenMatchesCreds &&
|
|
121
|
+
tokensShareSubject);
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=machine-token-identity.js.map
|
|
@@ -18,7 +18,15 @@ export interface ReadLanesFlagOptions {
|
|
|
18
18
|
env?: NodeJS.ProcessEnv;
|
|
19
19
|
createClient?: LanesFlagClientFactory;
|
|
20
20
|
getToken?: () => string | Promise<string>;
|
|
21
|
+
/** Second token source used once after a 401. Defaults to `getToken`, else a forced session refresh. */
|
|
22
|
+
refreshToken?: () => string | Promise<string>;
|
|
21
23
|
reportFailure?: (error: unknown) => void;
|
|
24
|
+
/** Fetch used by the default flag client; tests inject this to drive HTTP outcomes. */
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
/** Last-known-good cache directory; null disables it. Defaults to the HQ state dir. */
|
|
27
|
+
cacheDir?: string | null;
|
|
28
|
+
retryBackoffMs?: number;
|
|
29
|
+
sleep?: (ms: number) => Promise<void>;
|
|
22
30
|
}
|
|
23
31
|
export declare function readLanesFlagLocalOverride(flagKey: string, env?: NodeJS.ProcessEnv): boolean | null;
|
|
24
32
|
export type LanesRegistryFlagResult = {
|
|
@@ -27,10 +35,23 @@ export type LanesRegistryFlagResult = {
|
|
|
27
35
|
} | {
|
|
28
36
|
state: "unknown";
|
|
29
37
|
};
|
|
38
|
+
export declare const LANES_FLAG_RETRY_BACKOFF_MS = 250;
|
|
39
|
+
export declare const LANES_FLAG_CACHE_FILE = "lanes-flag-cache.json";
|
|
40
|
+
/**
|
|
41
|
+
* Oldest last-known-good entry a failed read may serve. Every successful
|
|
42
|
+
* snapshot read restamps resolvedAt, so this bounds how long a value can
|
|
43
|
+
* outlive its last confirmation by the registry.
|
|
44
|
+
*/
|
|
45
|
+
export declare const LANES_FLAG_CACHE_MAX_AGE_MS: number;
|
|
46
|
+
/** Error message safe for stderr: token-like substrings removed, length bounded. */
|
|
47
|
+
export declare function sanitizeFlagErrorMessage(error: unknown): string;
|
|
30
48
|
/**
|
|
31
49
|
* Read a lanes flag once for each company identity in this CLI process. The
|
|
32
|
-
* cache
|
|
33
|
-
*
|
|
50
|
+
* in-process cache holds the in-flight or settled result. The last registry
|
|
51
|
+
* snapshot value per flag and company is also kept in the HQ state dir and
|
|
52
|
+
* used when a later read fails transiently (timeout, network error, 5xx) and
|
|
53
|
+
* the value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS. A local override
|
|
54
|
+
* is checked on every call and wins first.
|
|
34
55
|
*/
|
|
35
56
|
export declare function readLanesRegistryFlag(flagKey: string, options?: ReadLanesFlagOptions): Promise<boolean>;
|
|
36
57
|
/** Read a lanes flag while preserving registry failure as an explicit unknown state. */
|