@indigoai-us/hq-cli 5.345.154 → 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.
@@ -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). Other unsynced
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() || fs.existsSync(`${abs}${PLACEHOLDER_SUFFIX}`))
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);
@@ -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
- /** Authenticate without using hq-cloud's persistent machine token cache. */
61
- export async function authenticateMachineCredentials(credentials, options = {}) {
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.clientId ?? env.HQ_COGNITO_CLIENT_ID ?? DEFAULT_COGNITO.clientId;
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", "hqd sign-in failed; check the machine credentials and try again");
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", "hqd sign-in failed; check the machine credentials and try again");
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", "hqd sign-in failed; check the machine credentials and try again");
99
+ throw new MachineLoginError("auth_failed", failureMessage);
94
100
  }
95
101
  const result = body.AuthenticationResult;
96
- const idToken = result && typeof result === "object" ? result.IdToken : undefined;
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", "hqd sign-in failed; check the machine credentials and try again");
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 contains only an in-flight or settled decision result; it is never
33
- * written to disk. A local override is checked on every call and wins first.
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. */