@hasna/recordings 0.3.2 → 0.3.9
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/README.md +38 -34
- package/bun.lock +96 -36
- package/dist/__tests__/helpers/source-assertions.d.ts +6 -4
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -1
- package/dist/cli/index.js +1059 -219
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/http/client.d.ts +6 -14
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.js +1971 -175
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/release-install-policy.d.ts +21 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -1
- package/dist/mcp/index.js +978 -142
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +7 -3
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +4 -19
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +1 -1
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +217 -299
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1881 -90
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +4 -3
- package/packaging/macos/build_release_pkg.sh +10 -4
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -0
- package/scripts/generate-sdk.ts +17 -16
- package/scripts/install_macos_app.sh +22 -22
- package/scripts/macos_artifact.ts +48 -39
- package/scripts/migrate.ts +2 -2
- package/scripts/release-suite-gate.ts +174 -0
- package/scripts/set-version.ts +5 -5
- package/scripts/smoke_macos_app.sh +21 -21
- package/scripts/vacuity-manifests/version-sites.tsv +8 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +2 -2
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +14 -20
- package/dist/lib/retired-deployment-modes.d.ts +0 -24
- package/dist/lib/retired-deployment-modes.d.ts.map +0 -1
package/dist/storage.js
CHANGED
|
@@ -1019,9 +1019,118 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1019
1019
|
d.query("UPDATE agents SET active_project_id = ?, last_seen_at = ? WHERE id = ?").run(resolvedProjectId, new Date().toISOString(), agent.id);
|
|
1020
1020
|
return getAgent(agent.id, d);
|
|
1021
1021
|
}
|
|
1022
|
+
// package.json
|
|
1023
|
+
var package_default = {
|
|
1024
|
+
name: "@hasna/recordings",
|
|
1025
|
+
version: "0.3.9",
|
|
1026
|
+
type: "module",
|
|
1027
|
+
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
1028
|
+
repository: {
|
|
1029
|
+
type: "git",
|
|
1030
|
+
url: "git+https://github.com/hasna/apps"
|
|
1031
|
+
},
|
|
1032
|
+
main: "dist/index.js",
|
|
1033
|
+
types: "dist/index.d.ts",
|
|
1034
|
+
bin: {
|
|
1035
|
+
recordings: "dist/cli/index.js",
|
|
1036
|
+
"recordings-mcp": "dist/mcp/index.js",
|
|
1037
|
+
"recordings-serve": "dist/server/index.js"
|
|
1038
|
+
},
|
|
1039
|
+
exports: {
|
|
1040
|
+
".": {
|
|
1041
|
+
import: "./dist/index.js",
|
|
1042
|
+
types: "./dist/index.d.ts"
|
|
1043
|
+
},
|
|
1044
|
+
"./storage": {
|
|
1045
|
+
import: "./dist/storage.js",
|
|
1046
|
+
types: "./dist/storage.d.ts"
|
|
1047
|
+
},
|
|
1048
|
+
"./sdk": {
|
|
1049
|
+
import: "./dist/sdk/index.js",
|
|
1050
|
+
types: "./dist/sdk/index.d.ts"
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
engines: {
|
|
1054
|
+
bun: ">=1.0.0"
|
|
1055
|
+
},
|
|
1056
|
+
scripts: {
|
|
1057
|
+
clean: "rm -rf dist",
|
|
1058
|
+
build: "bun run clean && bun run build:cli && bun run build:mcp && bun run build:serve && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
1059
|
+
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
1060
|
+
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
1061
|
+
"build:serve": "bun build src/server/index.ts --target=bun --outfile=dist/server/index.js --external=@modelcontextprotocol/sdk --external=@hasna/contracts --external=openai --external=pg",
|
|
1062
|
+
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai",
|
|
1063
|
+
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
1064
|
+
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
1065
|
+
migrate: "bun run scripts/migrate.ts",
|
|
1066
|
+
typecheck: "tsc --noEmit",
|
|
1067
|
+
"typecheck:tcc-contract": "tsc --noEmit -p tsconfig.tcc-contract.json",
|
|
1068
|
+
test: "bun test",
|
|
1069
|
+
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
1070
|
+
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
1071
|
+
"verify:ci-native": "bun scripts/ci-native-build.ts",
|
|
1072
|
+
"test:gated": 'bun scripts/ci-linux-suite.ts --check && RECORDINGS_TEST_TIMEOUT_MS="${RECORDINGS_TEST_TIMEOUT_MS:-120000}" bun test --timeout "$RECORDINGS_TEST_TIMEOUT_MS" $(bun scripts/ci-linux-suite.ts --gated)',
|
|
1073
|
+
"test:vacuity-battery": 'bun scripts/vacuity-manifest-gen.ts > "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv" && bun scripts/vacuity-mutation-battery.ts "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv"',
|
|
1074
|
+
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
1075
|
+
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
1076
|
+
"test:vacuity-battery:vars": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/variable-operands.tsv",
|
|
1077
|
+
"test:vacuity-battery:reorder": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/reorder.tsv",
|
|
1078
|
+
"test:coverage": "bun test --coverage",
|
|
1079
|
+
"dev:cli": "bun run src/cli/index.ts",
|
|
1080
|
+
"desktop:snapshot": "bun run src/cli/index.ts app snapshot",
|
|
1081
|
+
"dev:mcp": "bun run src/mcp/index.ts",
|
|
1082
|
+
"verify:release": "bun run scripts/release-guard.ts",
|
|
1083
|
+
"scan:artifact": "bun run scripts/scan-artifact.ts",
|
|
1084
|
+
"version:set": "bun run scripts/set-version.ts",
|
|
1085
|
+
"version:check": "bun run scripts/set-version.ts --check",
|
|
1086
|
+
prepack: "bun run prepack:platform-gate && bun run version:check && bun run build && bun run verify:release && bun run scan:artifact",
|
|
1087
|
+
"prepack:platform-gate": `bash -c 'if [ "$(uname -s)" = Darwin ]; then bun run build:native-fs-guard; else echo "WARN: native fs-guard build skipped on $(uname -s) \u2014 dry-run/CI pack; publish recordings from macOS"; fi'`,
|
|
1088
|
+
prepublishOnly: "bun run typecheck && bun run release-suite-gate",
|
|
1089
|
+
"release-suite-gate": "bun run scripts/release-suite-gate.ts",
|
|
1090
|
+
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json"
|
|
1091
|
+
},
|
|
1092
|
+
files: [
|
|
1093
|
+
"dist/",
|
|
1094
|
+
"scripts/",
|
|
1095
|
+
"Dockerfile.package",
|
|
1096
|
+
"bun.lock",
|
|
1097
|
+
"src/native/Recordings/App/",
|
|
1098
|
+
"src/native/Recordings/RecordingsLib/",
|
|
1099
|
+
"src/native/Recordings/RecordingsTests/",
|
|
1100
|
+
"src/native/Recordings/Updater/",
|
|
1101
|
+
"src/native/Recordings/Package.swift",
|
|
1102
|
+
"src/native/Recordings/Package.resolved",
|
|
1103
|
+
"src/native/Recordings/build.sh",
|
|
1104
|
+
"packaging/macos/",
|
|
1105
|
+
"README.md",
|
|
1106
|
+
"LICENSE"
|
|
1107
|
+
],
|
|
1108
|
+
dependencies: {
|
|
1109
|
+
"@hasna/contracts": "0.13.4",
|
|
1110
|
+
"@hasna/events": "0.1.11",
|
|
1111
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
1112
|
+
chalk: "^5.4.1",
|
|
1113
|
+
commander: "^13.1.0",
|
|
1114
|
+
openai: "^5.1.0",
|
|
1115
|
+
pg: "^8.13.3",
|
|
1116
|
+
zod: "^3.24.2"
|
|
1117
|
+
},
|
|
1118
|
+
devDependencies: {
|
|
1119
|
+
"@types/bun": "^1.2.5",
|
|
1120
|
+
"@types/pg": "^8.11.11",
|
|
1121
|
+
"node-api-headers": "1.9.0",
|
|
1122
|
+
typescript: "^5.8.2"
|
|
1123
|
+
},
|
|
1124
|
+
publishConfig: {
|
|
1125
|
+
registry: "https://registry.npmjs.org",
|
|
1126
|
+
access: "public"
|
|
1127
|
+
},
|
|
1128
|
+
license: "Apache-2.0",
|
|
1129
|
+
author: "Hasna <andrei@hasna.com>"
|
|
1130
|
+
};
|
|
1022
1131
|
|
|
1023
1132
|
// src/version.ts
|
|
1024
|
-
var VERSION =
|
|
1133
|
+
var VERSION = package_default.version;
|
|
1025
1134
|
|
|
1026
1135
|
// src/db/feedback.ts
|
|
1027
1136
|
function saveFeedback(input) {
|
|
@@ -1029,59 +1138,1703 @@ function saveFeedback(input) {
|
|
|
1029
1138
|
db.query("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)").run(input.message, input.email ?? null, input.category ?? "general", input.version ?? VERSION);
|
|
1030
1139
|
}
|
|
1031
1140
|
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
"
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
return
|
|
1141
|
+
// ../contracts/dist/client/transport.js
|
|
1142
|
+
import { isIP } from "net";
|
|
1143
|
+
import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
1144
|
+
import { join as join3 } from "path";
|
|
1145
|
+
function envToken(name) {
|
|
1146
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
1147
|
+
}
|
|
1148
|
+
function clientTransportEnvKeys(name) {
|
|
1149
|
+
const envSegment = envToken(name);
|
|
1150
|
+
return {
|
|
1151
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
1152
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
function credentialOverrideEnvKey(name) {
|
|
1156
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
1157
|
+
}
|
|
1158
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
1159
|
+
|
|
1160
|
+
class CredentialResolutionError extends Error {
|
|
1161
|
+
appName;
|
|
1162
|
+
attempted;
|
|
1163
|
+
constructor(appName, message, attempted) {
|
|
1164
|
+
super(message);
|
|
1165
|
+
this.name = "CredentialResolutionError";
|
|
1166
|
+
this.appName = appName;
|
|
1167
|
+
this.attempted = attempted;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
var HASNA_STATE_DIR = ".hasna";
|
|
1171
|
+
var FLEET_CREDENTIAL_DIR = "cloud";
|
|
1172
|
+
var CONFIG_DIR = ".config";
|
|
1173
|
+
var CONFIG_NAMESPACE = "hasna";
|
|
1174
|
+
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
1175
|
+
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1176
|
+
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
1177
|
+
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
1178
|
+
function homeDir(env) {
|
|
1179
|
+
const home = env.HOME?.trim();
|
|
1180
|
+
return home ? home : null;
|
|
1181
|
+
}
|
|
1182
|
+
function credentialDiskSources(name, env) {
|
|
1183
|
+
return profileDiskSources(name, env, null);
|
|
1184
|
+
}
|
|
1185
|
+
function profileDiskSources(name, env, profile) {
|
|
1186
|
+
const home = homeDir(env);
|
|
1187
|
+
if (!home || !SAFE_APP_SLUG.test(name))
|
|
1188
|
+
return [];
|
|
1189
|
+
const stem = profile ? `${name}.${profile}` : name;
|
|
1190
|
+
const configStem = profile ? `${name}-${profile}` : name;
|
|
1191
|
+
return [
|
|
1192
|
+
join3(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
|
|
1193
|
+
join3(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
|
|
1194
|
+
];
|
|
1195
|
+
}
|
|
1196
|
+
function parseEnvFile(text) {
|
|
1197
|
+
const values = new Map;
|
|
1198
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1199
|
+
const line = rawLine.trim();
|
|
1200
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
1201
|
+
continue;
|
|
1202
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
1203
|
+
const equals = withoutExport.indexOf("=");
|
|
1204
|
+
if (equals <= 0)
|
|
1205
|
+
continue;
|
|
1206
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
1207
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1208
|
+
continue;
|
|
1209
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
1210
|
+
const quote = value[0];
|
|
1211
|
+
if (quote === '"' || quote === "'") {
|
|
1212
|
+
if (value.length < 2 || !value.endsWith(quote))
|
|
1213
|
+
continue;
|
|
1214
|
+
value = value.slice(1, -1);
|
|
1215
|
+
}
|
|
1216
|
+
if (value.length === 0)
|
|
1217
|
+
continue;
|
|
1218
|
+
values.set(key, value);
|
|
1219
|
+
}
|
|
1220
|
+
return values;
|
|
1221
|
+
}
|
|
1222
|
+
function readAppConfigFile(path) {
|
|
1223
|
+
let text;
|
|
1224
|
+
try {
|
|
1225
|
+
const stats = statSync2(path);
|
|
1226
|
+
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
1227
|
+
return null;
|
|
1228
|
+
text = readFileSync2(path, "utf8");
|
|
1229
|
+
} catch {
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
return parseEnvFile(text);
|
|
1233
|
+
}
|
|
1234
|
+
function readCredentialFile(path, apiKeyKeys) {
|
|
1235
|
+
const values = readAppConfigFile(path);
|
|
1236
|
+
if (!values)
|
|
1237
|
+
return null;
|
|
1238
|
+
for (const key of apiKeyKeys) {
|
|
1239
|
+
const value = values.get(key)?.trim();
|
|
1240
|
+
if (value)
|
|
1241
|
+
return value;
|
|
1242
|
+
}
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|
|
1245
|
+
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
1246
|
+
function appConfigDiskValue(name, env, keys) {
|
|
1247
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
|
|
1248
|
+
if (wanted.length === 0)
|
|
1249
|
+
return null;
|
|
1250
|
+
for (const path of credentialDiskSources(name, env)) {
|
|
1251
|
+
const values = readAppConfigFile(path);
|
|
1252
|
+
if (!values)
|
|
1253
|
+
continue;
|
|
1254
|
+
for (const key of wanted) {
|
|
1255
|
+
const value = values.get(key)?.trim();
|
|
1256
|
+
if (value)
|
|
1257
|
+
return { key, value, path };
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
1262
|
+
function assertUsableCredential(appName, source, value) {
|
|
1263
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
1264
|
+
return;
|
|
1265
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
1266
|
+
}
|
|
1267
|
+
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
1268
|
+
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
1269
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
1270
|
+
function sealCredential(fields) {
|
|
1271
|
+
const { apiKey } = fields;
|
|
1272
|
+
const visible = {
|
|
1273
|
+
tier: fields.tier,
|
|
1274
|
+
source: fields.source,
|
|
1275
|
+
deliberate: fields.deliberate,
|
|
1276
|
+
deprecated: fields.deprecated,
|
|
1277
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
1278
|
+
warning: fields.warning
|
|
1279
|
+
};
|
|
1280
|
+
const sealed = { ...visible };
|
|
1281
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
1282
|
+
value: apiKey,
|
|
1283
|
+
enumerable: false,
|
|
1284
|
+
writable: false,
|
|
1285
|
+
configurable: false
|
|
1286
|
+
});
|
|
1287
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
1288
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
1289
|
+
enumerable: false,
|
|
1290
|
+
writable: false,
|
|
1291
|
+
configurable: false
|
|
1292
|
+
});
|
|
1293
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
1294
|
+
value: true,
|
|
1295
|
+
enumerable: false,
|
|
1296
|
+
writable: false,
|
|
1297
|
+
configurable: false
|
|
1298
|
+
});
|
|
1299
|
+
return Object.freeze(sealed);
|
|
1300
|
+
}
|
|
1301
|
+
function isSealedCredential(credential) {
|
|
1302
|
+
return credential[CREDENTIAL_SEAL] === true;
|
|
1303
|
+
}
|
|
1304
|
+
function explicitCredential(appName, apiKey) {
|
|
1305
|
+
const source = "explicit apiKey option";
|
|
1306
|
+
assertUsableCredential(appName, source, apiKey);
|
|
1307
|
+
return sealCredential({
|
|
1308
|
+
apiKey,
|
|
1309
|
+
tier: "argument",
|
|
1310
|
+
source,
|
|
1311
|
+
deliberate: true,
|
|
1312
|
+
deprecated: false,
|
|
1313
|
+
diskCandidates: [],
|
|
1314
|
+
warning: null
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
1318
|
+
const apiKey = credential.apiKey;
|
|
1319
|
+
assertUsableCredential(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
1320
|
+
if (!isSealedCredential(credential)) {
|
|
1321
|
+
return sealCredential({
|
|
1322
|
+
apiKey,
|
|
1323
|
+
tier: "argument",
|
|
1324
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
1325
|
+
deliberate: true,
|
|
1326
|
+
deprecated: false,
|
|
1327
|
+
diskCandidates: [],
|
|
1328
|
+
warning: null
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
return sealCredential({
|
|
1332
|
+
apiKey,
|
|
1333
|
+
tier: credential.tier,
|
|
1334
|
+
source: credential.source,
|
|
1335
|
+
deliberate: credential.deliberate,
|
|
1336
|
+
deprecated: credential.deprecated,
|
|
1337
|
+
diskCandidates: credential.diskCandidates,
|
|
1338
|
+
warning: credential.warning
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
function firstEnvValue(env, keys) {
|
|
1342
|
+
for (const key of keys) {
|
|
1343
|
+
const value = env[key]?.trim();
|
|
1344
|
+
if (value)
|
|
1345
|
+
return { key, value };
|
|
1346
|
+
}
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
1350
|
+
function deprecationNotified() {
|
|
1351
|
+
const host = globalThis;
|
|
1352
|
+
const existing = host[DEPRECATION_REGISTRY];
|
|
1353
|
+
if (existing instanceof Set)
|
|
1354
|
+
return existing;
|
|
1355
|
+
const created = new Set;
|
|
1356
|
+
host[DEPRECATION_REGISTRY] = created;
|
|
1357
|
+
return created;
|
|
1358
|
+
}
|
|
1359
|
+
function defaultDeprecationSink(message) {
|
|
1360
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
1361
|
+
process.stderr.write(`${message}
|
|
1362
|
+
`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
function resolveCredential(name, env, options = {}) {
|
|
1366
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
1367
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
1368
|
+
const explicitKey = options.apiKey?.trim();
|
|
1369
|
+
if (explicitKey) {
|
|
1370
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
1371
|
+
return sealCredential({
|
|
1372
|
+
apiKey: explicitKey,
|
|
1373
|
+
tier: "argument",
|
|
1374
|
+
source: "explicit apiKey argument",
|
|
1375
|
+
deliberate: true,
|
|
1376
|
+
deprecated: false,
|
|
1377
|
+
diskCandidates: diskPaths,
|
|
1378
|
+
warning: null
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
1382
|
+
const overrideRaw = env[overrideKeyName];
|
|
1383
|
+
if (overrideRaw !== undefined) {
|
|
1384
|
+
const override = overrideRaw.trim();
|
|
1385
|
+
if (!override) {
|
|
1386
|
+
throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
|
|
1387
|
+
}
|
|
1388
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
1389
|
+
return sealCredential({
|
|
1390
|
+
apiKey: override,
|
|
1391
|
+
tier: "override",
|
|
1392
|
+
source: overrideKeyName,
|
|
1393
|
+
deliberate: true,
|
|
1394
|
+
deprecated: false,
|
|
1395
|
+
diskCandidates: diskPaths,
|
|
1396
|
+
warning: null
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
|
|
1400
|
+
if (profile) {
|
|
1401
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
1402
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
1403
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
1404
|
+
}
|
|
1405
|
+
const paths = profileDiskSources(name, env, profile);
|
|
1406
|
+
for (const path of paths) {
|
|
1407
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
1408
|
+
if (value) {
|
|
1409
|
+
assertUsableCredential(name, path, value);
|
|
1410
|
+
return sealCredential({
|
|
1411
|
+
apiKey: value,
|
|
1412
|
+
tier: "profile",
|
|
1413
|
+
source: path,
|
|
1414
|
+
deliberate: true,
|
|
1415
|
+
deprecated: false,
|
|
1416
|
+
diskCandidates: paths,
|
|
1417
|
+
warning: null
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
|
|
1422
|
+
}
|
|
1423
|
+
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
1424
|
+
if (diskHits.length > 0) {
|
|
1425
|
+
const winner = diskHits[0];
|
|
1426
|
+
assertUsableCredential(name, winner.path, winner.value);
|
|
1427
|
+
const divergentSources = [
|
|
1428
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
1429
|
+
...(() => {
|
|
1430
|
+
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
1431
|
+
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
1432
|
+
})()
|
|
1433
|
+
];
|
|
1434
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
1435
|
+
return sealCredential({
|
|
1436
|
+
apiKey: winner.value,
|
|
1437
|
+
tier: "disk",
|
|
1438
|
+
source: winner.path,
|
|
1439
|
+
deliberate: false,
|
|
1440
|
+
deprecated: false,
|
|
1441
|
+
diskCandidates: diskPaths,
|
|
1442
|
+
warning
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
const legacy = firstEnvValue(env, apiKeyKeys);
|
|
1446
|
+
if (legacy) {
|
|
1447
|
+
assertUsableCredential(name, legacy.key, legacy.value);
|
|
1448
|
+
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
1449
|
+
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
1450
|
+
const sink = options.onDeprecation ?? defaultDeprecationSink;
|
|
1451
|
+
const notified = deprecationNotified();
|
|
1452
|
+
if (!notified.has(name)) {
|
|
1453
|
+
notified.add(name);
|
|
1454
|
+
sink(message);
|
|
1455
|
+
}
|
|
1456
|
+
return sealCredential({
|
|
1457
|
+
apiKey: legacy.value,
|
|
1458
|
+
tier: "legacy-env",
|
|
1459
|
+
source: legacy.key,
|
|
1460
|
+
deliberate: false,
|
|
1461
|
+
deprecated: true,
|
|
1462
|
+
diskCandidates: diskPaths,
|
|
1463
|
+
warning: message
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
1469
|
+
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1470
|
+
function isValidDnsDomain(value) {
|
|
1471
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1474
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
1475
|
+
}
|
|
1476
|
+
function firstEnv(env, keys, options = {}) {
|
|
1477
|
+
for (const key of keys) {
|
|
1478
|
+
const raw = env[key];
|
|
1479
|
+
const value = raw?.trim();
|
|
1480
|
+
if (value)
|
|
1481
|
+
return { key, value: options.preserveRaw ? raw : value };
|
|
1482
|
+
}
|
|
1483
|
+
return null;
|
|
1484
|
+
}
|
|
1485
|
+
function firstEnvDefinedKey(env, keys) {
|
|
1486
|
+
for (const key of keys) {
|
|
1487
|
+
if (env[key] !== undefined)
|
|
1488
|
+
return key;
|
|
1489
|
+
}
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
1492
|
+
function rawAuthority(value) {
|
|
1493
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
1494
|
+
if (!match)
|
|
1495
|
+
throw new Error("API URL must be absolute.");
|
|
1496
|
+
const afterScheme = value.slice(match[0].length);
|
|
1497
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
1498
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
1499
|
+
if (!authority)
|
|
1500
|
+
throw new Error("API URL must include a hostname.");
|
|
1501
|
+
return authority;
|
|
1502
|
+
}
|
|
1503
|
+
function assertCanonicalPort(port) {
|
|
1504
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
1505
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
1506
|
+
}
|
|
1507
|
+
const numericPort = Number(port);
|
|
1508
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
1509
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
function canonicalAuthorityHostname(authority) {
|
|
1513
|
+
let rawHostname;
|
|
1514
|
+
if (authority.startsWith("[")) {
|
|
1515
|
+
const closingBracket = authority.indexOf("]");
|
|
1516
|
+
if (closingBracket === -1) {
|
|
1517
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
1518
|
+
}
|
|
1519
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
1520
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
1521
|
+
if (portSuffix) {
|
|
1522
|
+
if (!portSuffix.startsWith(":")) {
|
|
1523
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
1524
|
+
}
|
|
1525
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
1526
|
+
}
|
|
1527
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
1528
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
1529
|
+
}
|
|
1530
|
+
} else {
|
|
1531
|
+
const firstColon = authority.indexOf(":");
|
|
1532
|
+
const lastColon = authority.lastIndexOf(":");
|
|
1533
|
+
if (firstColon !== lastColon) {
|
|
1534
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
1535
|
+
}
|
|
1536
|
+
if (lastColon !== -1) {
|
|
1537
|
+
const port = authority.slice(lastColon + 1);
|
|
1538
|
+
assertCanonicalPort(port);
|
|
1539
|
+
rawHostname = authority.slice(0, lastColon);
|
|
1540
|
+
} else {
|
|
1541
|
+
rawHostname = authority;
|
|
1542
|
+
}
|
|
1543
|
+
const ipVersion = isIP(rawHostname);
|
|
1544
|
+
const numericAddressParts = rawHostname.split(".");
|
|
1545
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
1546
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
1547
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return rawHostname.toLowerCase();
|
|
1551
|
+
}
|
|
1552
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
1553
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
1554
|
+
}
|
|
1555
|
+
function toV1BaseUrl(apiUrl) {
|
|
1556
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
1557
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
1558
|
+
}
|
|
1559
|
+
const input = apiUrl.trim();
|
|
1560
|
+
const authority = rawAuthority(input);
|
|
1561
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
1562
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
1563
|
+
}
|
|
1564
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
1565
|
+
const url = new URL(input);
|
|
1566
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1567
|
+
throw new Error("API URL must use http or https.");
|
|
1568
|
+
}
|
|
1569
|
+
if (url.username || url.password) {
|
|
1570
|
+
throw new Error("API URL must not include credentials.");
|
|
1571
|
+
}
|
|
1572
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
1573
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
1574
|
+
}
|
|
1575
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
1576
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
1577
|
+
}
|
|
1578
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
1579
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
1580
|
+
}
|
|
1581
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
1582
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
1583
|
+
}
|
|
1584
|
+
if (url.search || url.hash) {
|
|
1585
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
1586
|
+
}
|
|
1587
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
1588
|
+
if (path.endsWith("/v1"))
|
|
1589
|
+
path = path.slice(0, -"/v1".length);
|
|
1590
|
+
url.pathname = `${path}/v1`;
|
|
1591
|
+
return url.toString().replace(/\/+$/, "");
|
|
1592
|
+
}
|
|
1593
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
1594
|
+
const keys = clientTransportEnvKeys(name);
|
|
1595
|
+
const envUrlHit = firstEnv(env, keys.apiUrlKeys, { preserveRaw: true });
|
|
1596
|
+
const explicitLocalKey = envUrlHit ? null : firstEnvDefinedKey(env, keys.apiUrlKeys);
|
|
1597
|
+
const diskUrlHit = envUrlHit || explicitLocalKey ? null : appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
1598
|
+
const urlHit = envUrlHit ?? (diskUrlHit ? { key: diskUrlHit.path, value: diskUrlHit.value } : null);
|
|
1599
|
+
const keyHit = firstEnv(env, keys.apiKeyKeys);
|
|
1600
|
+
const warnings = [];
|
|
1601
|
+
if (!urlHit) {
|
|
1602
|
+
if (explicitLocalKey) {
|
|
1603
|
+
const overriddenPointer = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
1604
|
+
if (overriddenPointer) {
|
|
1605
|
+
warnings.push(`${explicitLocalKey} is defined but blank, which selects the local store. ` + `The server URL in ${overriddenPointer.path} was NOT selected: an explicit blank wins over a disk pointer.`);
|
|
1606
|
+
}
|
|
1607
|
+
return {
|
|
1608
|
+
transport: "sqlite",
|
|
1609
|
+
transportSource: explicitLocalKey,
|
|
1610
|
+
baseUrl: null,
|
|
1611
|
+
apiUrlSource: null,
|
|
1612
|
+
apiKeyPresent: Boolean(keyHit),
|
|
1613
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
1614
|
+
apiKeyTier: null,
|
|
1615
|
+
misconfigured: false,
|
|
1616
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
return {
|
|
1620
|
+
transport: "sqlite",
|
|
1621
|
+
transportSource: "default",
|
|
1622
|
+
baseUrl: null,
|
|
1623
|
+
apiUrlSource: null,
|
|
1624
|
+
apiKeyPresent: Boolean(keyHit),
|
|
1625
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
1626
|
+
apiKeyTier: null,
|
|
1627
|
+
misconfigured: false,
|
|
1628
|
+
warning: null
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
if (diskUrlHit) {
|
|
1632
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${diskUrlHit.path} was used, so this client connects to the server. ` + `Unset the pointer or remove the file to stay on the local store.`);
|
|
1633
|
+
}
|
|
1634
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
1635
|
+
if (!credential) {
|
|
1636
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
1637
|
+
warnings.push(`${urlHit.key} selects the HTTP server for '${name}', but no API key could be resolved; ` + `refusing to route and leaving the local sqlite store selected. ` + `Looked for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
1638
|
+
return {
|
|
1639
|
+
transport: "sqlite",
|
|
1640
|
+
transportSource: urlHit.key,
|
|
1641
|
+
baseUrl: null,
|
|
1642
|
+
apiUrlSource: urlHit.key,
|
|
1643
|
+
apiKeyPresent: false,
|
|
1644
|
+
apiKeySource: null,
|
|
1645
|
+
apiKeyTier: null,
|
|
1646
|
+
misconfigured: true,
|
|
1647
|
+
warning: warnings.join(" ")
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
if (credential.warning)
|
|
1651
|
+
warnings.push(credential.warning);
|
|
1652
|
+
const apiUrlSource = urlHit.key;
|
|
1653
|
+
let baseUrl;
|
|
1654
|
+
try {
|
|
1655
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1658
|
+
warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
|
|
1659
|
+
return {
|
|
1660
|
+
transport: "sqlite",
|
|
1661
|
+
transportSource: urlHit.key,
|
|
1662
|
+
baseUrl: null,
|
|
1663
|
+
apiUrlSource: urlHit.key,
|
|
1664
|
+
apiKeyPresent: true,
|
|
1665
|
+
apiKeySource: credential.source,
|
|
1666
|
+
apiKeyTier: credential.tier,
|
|
1667
|
+
misconfigured: true,
|
|
1668
|
+
warning: warnings.join(" ")
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
return {
|
|
1672
|
+
transport: "http",
|
|
1673
|
+
transportSource: urlHit.key,
|
|
1674
|
+
baseUrl,
|
|
1675
|
+
apiUrlSource,
|
|
1676
|
+
apiKeyPresent: true,
|
|
1677
|
+
apiKeySource: credential.source,
|
|
1678
|
+
apiKeyTier: credential.tier,
|
|
1679
|
+
misconfigured: false,
|
|
1680
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
1684
|
+
const paths = credentialDiskSources(name, env);
|
|
1685
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
class HasnaHttpError extends Error {
|
|
1689
|
+
status;
|
|
1690
|
+
method;
|
|
1691
|
+
path;
|
|
1692
|
+
body;
|
|
1693
|
+
credentialSource;
|
|
1694
|
+
credentialTier;
|
|
1695
|
+
constructor(method, path, status, body, credential) {
|
|
1696
|
+
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
1697
|
+
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
1698
|
+
this.name = "HasnaHttpError";
|
|
1699
|
+
this.status = status;
|
|
1700
|
+
this.method = method;
|
|
1701
|
+
this.path = path;
|
|
1702
|
+
this.body = body;
|
|
1703
|
+
this.credentialSource = credential?.source ?? null;
|
|
1704
|
+
this.credentialTier = credential?.tier ?? null;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
function currentCredential(name, apiKey) {
|
|
1708
|
+
if (typeof apiKey === "function") {
|
|
1709
|
+
return validateAndSealResolvedCredential(name, apiKey());
|
|
1710
|
+
}
|
|
1711
|
+
return explicitCredential(name, apiKey);
|
|
1712
|
+
}
|
|
1713
|
+
function authFailureGuidance(credential) {
|
|
1714
|
+
const origin = `The API key for this request came from ${credential.source}`;
|
|
1715
|
+
if (credential.deliberate) {
|
|
1716
|
+
const remedy = credential.source === CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE ? `Fix that provider so it returns the current key, or replace it with resolveCredential() ` + `so diagnostics can name the original source.` : `Rotate that key, or unset the override to use the credential on disk.`;
|
|
1717
|
+
return `${origin} \u2014 a credential you selected deliberately. It was NOT substituted with any other key: ` + `falling back here would authenticate as a different principal than the one you named, which is ` + `exactly the failure an override exists to prevent. ${remedy}`;
|
|
1718
|
+
}
|
|
1719
|
+
if (credential.deprecated) {
|
|
1720
|
+
const target = credential.diskCandidates[0];
|
|
1721
|
+
const remedy = target ? `Write the CURRENT key to ${target} \u2014 that file is re-read on every call, so rotations take ` + `effect immediately and in every shell. Do not simply unset ${credential.source}: nothing was ` + `found on disk, so that would leave this client with no credential at all.` : `This environment has no HOME, so no credential file could be consulted; the disk tier is ` + `unavailable here and there is nothing to fall back to. Set HOME, or supply the key explicitly.`;
|
|
1722
|
+
return `${origin}, a variable in this process's environment \u2014 which is a snapshot taken when the process ` + `started. A STALE SHELL is the most common cause of this error: this shell exported the key before ` + `it was rotated, and will keep sending the old one until it exits. ${remedy}`;
|
|
1723
|
+
}
|
|
1724
|
+
return `${origin}, which was re-read from disk on this very call \u2014 so a stale shell is NOT the cause here. ` + `The stored credential is genuinely being rejected: rotate it, or re-run the fleet key distribution ` + `so this machine gets the current key.`;
|
|
1725
|
+
}
|
|
1726
|
+
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
1727
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
1728
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
1729
|
+
"host",
|
|
1730
|
+
":authority",
|
|
1731
|
+
"forwarded",
|
|
1732
|
+
"x-forwarded-host",
|
|
1733
|
+
"x-original-host"
|
|
1734
|
+
]);
|
|
1735
|
+
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
1736
|
+
if (!headers)
|
|
1737
|
+
return;
|
|
1738
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS.has(name.trim().toLowerCase()));
|
|
1739
|
+
if (forbidden) {
|
|
1740
|
+
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
function appendQuery(path, query) {
|
|
1744
|
+
if (!query)
|
|
1745
|
+
return path;
|
|
1746
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
1747
|
+
if (!(query instanceof URLSearchParams)) {
|
|
1748
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1749
|
+
if (value === null || value === undefined)
|
|
1750
|
+
continue;
|
|
1751
|
+
if (Array.isArray(value)) {
|
|
1752
|
+
for (const v of value)
|
|
1753
|
+
params.append(key, String(v));
|
|
1754
|
+
} else {
|
|
1755
|
+
params.append(key, String(value));
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
const qs = params.toString();
|
|
1760
|
+
if (!qs)
|
|
1761
|
+
return path;
|
|
1762
|
+
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
1763
|
+
}
|
|
1764
|
+
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1765
|
+
function createHasnaHttpTransport(options) {
|
|
1766
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1767
|
+
const base = toV1BaseUrl(options.baseUrl);
|
|
1768
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
1769
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
1770
|
+
const defaultRetry = options.retry;
|
|
1771
|
+
function resolveRetry(callRetry) {
|
|
1772
|
+
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
1773
|
+
if (chosen === false)
|
|
1774
|
+
return null;
|
|
1775
|
+
const r = chosen ?? {};
|
|
1776
|
+
return {
|
|
1777
|
+
retries: r.retries ?? 2,
|
|
1778
|
+
baseDelayMs: r.baseDelayMs ?? 200,
|
|
1779
|
+
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
1780
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
async function once(method, rel, url, body, opts, credential) {
|
|
1784
|
+
assertNoAuthorityOverrideHeaders(options.headers, "transport");
|
|
1785
|
+
assertNoAuthorityOverrideHeaders(opts.headers, "request");
|
|
1786
|
+
const headers = {
|
|
1787
|
+
"x-api-key": credential.apiKey,
|
|
1788
|
+
Authorization: `Bearer ${credential.apiKey}`,
|
|
1789
|
+
Accept: "application/json",
|
|
1790
|
+
...options.headers ?? {},
|
|
1791
|
+
...opts.headers ?? {}
|
|
1792
|
+
};
|
|
1793
|
+
if (opts.idempotencyKey)
|
|
1794
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
1795
|
+
const init = {
|
|
1796
|
+
method,
|
|
1797
|
+
headers,
|
|
1798
|
+
redirect: "manual"
|
|
1799
|
+
};
|
|
1800
|
+
if (body !== undefined) {
|
|
1801
|
+
headers["Content-Type"] = "application/json";
|
|
1802
|
+
init.body = JSON.stringify(body);
|
|
1803
|
+
}
|
|
1804
|
+
const controller = new AbortController;
|
|
1805
|
+
const onAbort = () => controller.abort();
|
|
1806
|
+
if (opts.signal) {
|
|
1807
|
+
if (opts.signal.aborted)
|
|
1808
|
+
controller.abort();
|
|
1809
|
+
else
|
|
1810
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
1811
|
+
}
|
|
1812
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
1813
|
+
init.signal = controller.signal;
|
|
1814
|
+
let response;
|
|
1815
|
+
try {
|
|
1816
|
+
response = await fetchImpl(url, init);
|
|
1817
|
+
} catch (error) {
|
|
1818
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1819
|
+
if (opts.signal?.aborted)
|
|
1820
|
+
return { ok: false, retryable: false, error: err };
|
|
1821
|
+
return { ok: false, retryable: true, error: err };
|
|
1822
|
+
} finally {
|
|
1823
|
+
clearTimeout(timer);
|
|
1824
|
+
if (opts.signal)
|
|
1825
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
1826
|
+
}
|
|
1827
|
+
const text = await response.text();
|
|
1828
|
+
let parsed = undefined;
|
|
1829
|
+
if (text.length > 0) {
|
|
1830
|
+
try {
|
|
1831
|
+
parsed = JSON.parse(text);
|
|
1832
|
+
} catch {
|
|
1833
|
+
parsed = text;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
if (!response.ok) {
|
|
1837
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1838
|
+
return {
|
|
1839
|
+
ok: false,
|
|
1840
|
+
retryable: false,
|
|
1841
|
+
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
if (response.status === 401 || response.status === 403) {
|
|
1845
|
+
return {
|
|
1846
|
+
ok: false,
|
|
1847
|
+
retryable: false,
|
|
1848
|
+
error: new HasnaHttpError(method, rel, response.status, parsed, {
|
|
1849
|
+
source: credential.source,
|
|
1850
|
+
tier: credential.tier,
|
|
1851
|
+
guidance: authFailureGuidance(credential)
|
|
1852
|
+
})
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
const retry = resolveRetry(opts.retry);
|
|
1856
|
+
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
1857
|
+
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
1858
|
+
}
|
|
1859
|
+
return { ok: true, value: parsed };
|
|
1860
|
+
}
|
|
1861
|
+
async function request(method, path, body, opts = {}) {
|
|
1862
|
+
const upper = method.toUpperCase();
|
|
1863
|
+
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
1864
|
+
const url = `${base}${rel}`;
|
|
1865
|
+
const retry = resolveRetry(opts.retry);
|
|
1866
|
+
const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
|
|
1867
|
+
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
1868
|
+
const credential = currentCredential(options.name, options.apiKey);
|
|
1869
|
+
let last = null;
|
|
1870
|
+
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
1871
|
+
const result = await once(upper, rel, url, body, opts, credential);
|
|
1872
|
+
if (result.ok)
|
|
1873
|
+
return result.value;
|
|
1874
|
+
last = result;
|
|
1875
|
+
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
1876
|
+
if (!canRetry)
|
|
1877
|
+
break;
|
|
1878
|
+
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
1879
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
1880
|
+
await sleep(backoff + jitter);
|
|
1881
|
+
}
|
|
1882
|
+
throw last.error;
|
|
1883
|
+
}
|
|
1884
|
+
return {
|
|
1885
|
+
baseUrl: base,
|
|
1886
|
+
request,
|
|
1887
|
+
get: (path, opts) => request("GET", path, undefined, opts),
|
|
1888
|
+
post: (path, body, opts) => request("POST", path, body, opts),
|
|
1889
|
+
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
1890
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
1891
|
+
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
function createClientTransport(name, env = process.env, overrides) {
|
|
1895
|
+
const credentialOptions = overrides?.credentials;
|
|
1896
|
+
const resolution = resolveClientTransport(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
|
|
1897
|
+
if (resolution.misconfigured) {
|
|
1898
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
|
|
1899
|
+
}
|
|
1900
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
1901
|
+
return { transport: "sqlite", client: null, resolution };
|
|
1902
|
+
}
|
|
1903
|
+
const credentialProvider = () => {
|
|
1904
|
+
const resolved = resolveCredential(name, env, credentialOptions);
|
|
1905
|
+
if (!resolved) {
|
|
1906
|
+
throw new Error(`Client for '${name}' resolved to the http transport but no API key is available any more. ` + `Looked at ${credentialDiskSourcesForMessage(name, env)}, then the environment. ` + `A credential file that was removed after this client was built is the usual cause.`);
|
|
1907
|
+
}
|
|
1908
|
+
return resolved;
|
|
1909
|
+
};
|
|
1910
|
+
return {
|
|
1911
|
+
transport: "http",
|
|
1912
|
+
client: createHasnaHttpTransport({
|
|
1913
|
+
name,
|
|
1914
|
+
baseUrl: resolution.baseUrl,
|
|
1915
|
+
apiKey: credentialProvider,
|
|
1916
|
+
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
1917
|
+
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
1918
|
+
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
1919
|
+
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
1920
|
+
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
1921
|
+
}),
|
|
1922
|
+
resolution
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// ../contracts/dist/client/storage.js
|
|
1927
|
+
import { isIP as isIP2 } from "net";
|
|
1928
|
+
import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
|
|
1929
|
+
import { join as join4 } from "path";
|
|
1930
|
+
function envToken2(name) {
|
|
1931
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
1932
|
+
}
|
|
1933
|
+
function clientTransportEnvKeys2(name) {
|
|
1934
|
+
const envSegment = envToken2(name);
|
|
1935
|
+
return {
|
|
1936
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
1937
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
function credentialOverrideEnvKey2(name) {
|
|
1941
|
+
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
1942
|
+
}
|
|
1943
|
+
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
1944
|
+
|
|
1945
|
+
class CredentialResolutionError2 extends Error {
|
|
1946
|
+
appName;
|
|
1947
|
+
attempted;
|
|
1948
|
+
constructor(appName, message, attempted) {
|
|
1949
|
+
super(message);
|
|
1950
|
+
this.name = "CredentialResolutionError";
|
|
1951
|
+
this.appName = appName;
|
|
1952
|
+
this.attempted = attempted;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
var HASNA_STATE_DIR2 = ".hasna";
|
|
1956
|
+
var FLEET_CREDENTIAL_DIR2 = "cloud";
|
|
1957
|
+
var CONFIG_DIR2 = ".config";
|
|
1958
|
+
var CONFIG_NAMESPACE2 = "hasna";
|
|
1959
|
+
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
1960
|
+
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1961
|
+
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
1962
|
+
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
1963
|
+
function homeDir2(env) {
|
|
1964
|
+
const home = env.HOME?.trim();
|
|
1965
|
+
return home ? home : null;
|
|
1966
|
+
}
|
|
1967
|
+
function credentialDiskSources2(name, env) {
|
|
1968
|
+
return profileDiskSources2(name, env, null);
|
|
1969
|
+
}
|
|
1970
|
+
function profileDiskSources2(name, env, profile) {
|
|
1971
|
+
const home = homeDir2(env);
|
|
1972
|
+
if (!home || !SAFE_APP_SLUG2.test(name))
|
|
1973
|
+
return [];
|
|
1974
|
+
const stem = profile ? `${name}.${profile}` : name;
|
|
1975
|
+
const configStem = profile ? `${name}-${profile}` : name;
|
|
1976
|
+
return [
|
|
1977
|
+
join4(home, HASNA_STATE_DIR2, FLEET_CREDENTIAL_DIR2, `${stem}.env`),
|
|
1978
|
+
join4(home, CONFIG_DIR2, CONFIG_NAMESPACE2, `${configStem}-cloud.env`)
|
|
1979
|
+
];
|
|
1980
|
+
}
|
|
1981
|
+
function parseEnvFile2(text) {
|
|
1982
|
+
const values = new Map;
|
|
1983
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1984
|
+
const line = rawLine.trim();
|
|
1985
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
1986
|
+
continue;
|
|
1987
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
1988
|
+
const equals = withoutExport.indexOf("=");
|
|
1989
|
+
if (equals <= 0)
|
|
1990
|
+
continue;
|
|
1991
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
1992
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1993
|
+
continue;
|
|
1994
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
1995
|
+
const quote = value[0];
|
|
1996
|
+
if (quote === '"' || quote === "'") {
|
|
1997
|
+
if (value.length < 2 || !value.endsWith(quote))
|
|
1998
|
+
continue;
|
|
1999
|
+
value = value.slice(1, -1);
|
|
2000
|
+
}
|
|
2001
|
+
if (value.length === 0)
|
|
2002
|
+
continue;
|
|
2003
|
+
values.set(key, value);
|
|
2004
|
+
}
|
|
2005
|
+
return values;
|
|
2006
|
+
}
|
|
2007
|
+
function readAppConfigFile2(path) {
|
|
2008
|
+
let text;
|
|
2009
|
+
try {
|
|
2010
|
+
const stats = statSync3(path);
|
|
2011
|
+
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
2012
|
+
return null;
|
|
2013
|
+
text = readFileSync3(path, "utf8");
|
|
2014
|
+
} catch {
|
|
2015
|
+
return null;
|
|
2016
|
+
}
|
|
2017
|
+
return parseEnvFile2(text);
|
|
2018
|
+
}
|
|
2019
|
+
function readCredentialFile2(path, apiKeyKeys) {
|
|
2020
|
+
const values = readAppConfigFile2(path);
|
|
2021
|
+
if (!values)
|
|
2022
|
+
return null;
|
|
2023
|
+
for (const key of apiKeyKeys) {
|
|
2024
|
+
const value = values.get(key)?.trim();
|
|
2025
|
+
if (value)
|
|
2026
|
+
return value;
|
|
2027
|
+
}
|
|
2028
|
+
return null;
|
|
2029
|
+
}
|
|
2030
|
+
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2031
|
+
function appConfigDiskValue2(name, env, keys) {
|
|
2032
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY2.test(key));
|
|
2033
|
+
if (wanted.length === 0)
|
|
2034
|
+
return null;
|
|
2035
|
+
for (const path of credentialDiskSources2(name, env)) {
|
|
2036
|
+
const values = readAppConfigFile2(path);
|
|
2037
|
+
if (!values)
|
|
2038
|
+
continue;
|
|
2039
|
+
for (const key of wanted) {
|
|
2040
|
+
const value = values.get(key)?.trim();
|
|
2041
|
+
if (value)
|
|
2042
|
+
return { key, value, path };
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
function assertUsableCredential2(appName, source, value) {
|
|
2048
|
+
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
2049
|
+
return;
|
|
2050
|
+
throw new CredentialResolutionError2(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
2051
|
+
}
|
|
2052
|
+
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
2053
|
+
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
2054
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2 = "caller-supplied CredentialProvider";
|
|
2055
|
+
function sealCredential2(fields) {
|
|
2056
|
+
const { apiKey } = fields;
|
|
2057
|
+
const visible = {
|
|
2058
|
+
tier: fields.tier,
|
|
2059
|
+
source: fields.source,
|
|
2060
|
+
deliberate: fields.deliberate,
|
|
2061
|
+
deprecated: fields.deprecated,
|
|
2062
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
2063
|
+
warning: fields.warning
|
|
2064
|
+
};
|
|
2065
|
+
const sealed = { ...visible };
|
|
2066
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
2067
|
+
value: apiKey,
|
|
2068
|
+
enumerable: false,
|
|
2069
|
+
writable: false,
|
|
2070
|
+
configurable: false
|
|
2071
|
+
});
|
|
2072
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
2073
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
2074
|
+
enumerable: false,
|
|
2075
|
+
writable: false,
|
|
2076
|
+
configurable: false
|
|
2077
|
+
});
|
|
2078
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
|
|
2079
|
+
value: true,
|
|
2080
|
+
enumerable: false,
|
|
2081
|
+
writable: false,
|
|
2082
|
+
configurable: false
|
|
2083
|
+
});
|
|
2084
|
+
return Object.freeze(sealed);
|
|
2085
|
+
}
|
|
2086
|
+
function isSealedCredential2(credential) {
|
|
2087
|
+
return credential[CREDENTIAL_SEAL2] === true;
|
|
2088
|
+
}
|
|
2089
|
+
function explicitCredential2(appName, apiKey) {
|
|
2090
|
+
const source = "explicit apiKey option";
|
|
2091
|
+
assertUsableCredential2(appName, source, apiKey);
|
|
2092
|
+
return sealCredential2({
|
|
2093
|
+
apiKey,
|
|
2094
|
+
tier: "argument",
|
|
2095
|
+
source,
|
|
2096
|
+
deliberate: true,
|
|
2097
|
+
deprecated: false,
|
|
2098
|
+
diskCandidates: [],
|
|
2099
|
+
warning: null
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
function validateAndSealResolvedCredential2(appName, credential) {
|
|
2103
|
+
const apiKey = credential.apiKey;
|
|
2104
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2, apiKey);
|
|
2105
|
+
if (!isSealedCredential2(credential)) {
|
|
2106
|
+
return sealCredential2({
|
|
2107
|
+
apiKey,
|
|
2108
|
+
tier: "argument",
|
|
2109
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2,
|
|
2110
|
+
deliberate: true,
|
|
2111
|
+
deprecated: false,
|
|
2112
|
+
diskCandidates: [],
|
|
2113
|
+
warning: null
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
return sealCredential2({
|
|
2117
|
+
apiKey,
|
|
2118
|
+
tier: credential.tier,
|
|
2119
|
+
source: credential.source,
|
|
2120
|
+
deliberate: credential.deliberate,
|
|
2121
|
+
deprecated: credential.deprecated,
|
|
2122
|
+
diskCandidates: credential.diskCandidates,
|
|
2123
|
+
warning: credential.warning
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
function firstEnvValue2(env, keys) {
|
|
2127
|
+
for (const key of keys) {
|
|
2128
|
+
const value = env[key]?.trim();
|
|
2129
|
+
if (value)
|
|
2130
|
+
return { key, value };
|
|
2131
|
+
}
|
|
2132
|
+
return null;
|
|
2133
|
+
}
|
|
2134
|
+
var DEPRECATION_REGISTRY2 = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
2135
|
+
function deprecationNotified2() {
|
|
2136
|
+
const host = globalThis;
|
|
2137
|
+
const existing = host[DEPRECATION_REGISTRY2];
|
|
2138
|
+
if (existing instanceof Set)
|
|
2139
|
+
return existing;
|
|
2140
|
+
const created = new Set;
|
|
2141
|
+
host[DEPRECATION_REGISTRY2] = created;
|
|
2142
|
+
return created;
|
|
2143
|
+
}
|
|
2144
|
+
function defaultDeprecationSink2(message) {
|
|
2145
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
2146
|
+
process.stderr.write(`${message}
|
|
2147
|
+
`);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
function resolveCredential2(name, env, options = {}) {
|
|
2151
|
+
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
2152
|
+
const diskPaths = credentialDiskSources2(name, env);
|
|
2153
|
+
const explicitKey = options.apiKey?.trim();
|
|
2154
|
+
if (explicitKey) {
|
|
2155
|
+
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
2156
|
+
return sealCredential2({
|
|
2157
|
+
apiKey: explicitKey,
|
|
2158
|
+
tier: "argument",
|
|
2159
|
+
source: "explicit apiKey argument",
|
|
2160
|
+
deliberate: true,
|
|
2161
|
+
deprecated: false,
|
|
2162
|
+
diskCandidates: diskPaths,
|
|
2163
|
+
warning: null
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
2167
|
+
const overrideRaw = env[overrideKeyName];
|
|
2168
|
+
if (overrideRaw !== undefined) {
|
|
2169
|
+
const override = overrideRaw.trim();
|
|
2170
|
+
if (!override) {
|
|
2171
|
+
throw new CredentialResolutionError2(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
|
|
2172
|
+
}
|
|
2173
|
+
assertUsableCredential2(name, overrideKeyName, override);
|
|
2174
|
+
return sealCredential2({
|
|
2175
|
+
apiKey: override,
|
|
2176
|
+
tier: "override",
|
|
2177
|
+
source: overrideKeyName,
|
|
2178
|
+
deliberate: true,
|
|
2179
|
+
deprecated: false,
|
|
2180
|
+
diskCandidates: diskPaths,
|
|
2181
|
+
warning: null
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY2]?.trim();
|
|
2185
|
+
if (profile) {
|
|
2186
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
2187
|
+
if (!SAFE_PROFILE2.test(profile)) {
|
|
2188
|
+
throw new CredentialResolutionError2(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
2189
|
+
}
|
|
2190
|
+
const paths = profileDiskSources2(name, env, profile);
|
|
2191
|
+
for (const path of paths) {
|
|
2192
|
+
const value = readCredentialFile2(path, apiKeyKeys);
|
|
2193
|
+
if (value) {
|
|
2194
|
+
assertUsableCredential2(name, path, value);
|
|
2195
|
+
return sealCredential2({
|
|
2196
|
+
apiKey: value,
|
|
2197
|
+
tier: "profile",
|
|
2198
|
+
source: path,
|
|
2199
|
+
deliberate: true,
|
|
2200
|
+
deprecated: false,
|
|
2201
|
+
diskCandidates: paths,
|
|
2202
|
+
warning: null
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
throw new CredentialResolutionError2(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY2}.`, paths);
|
|
2207
|
+
}
|
|
2208
|
+
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile2(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
2209
|
+
if (diskHits.length > 0) {
|
|
2210
|
+
const winner = diskHits[0];
|
|
2211
|
+
assertUsableCredential2(name, winner.path, winner.value);
|
|
2212
|
+
const divergentSources = [
|
|
2213
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
2214
|
+
...(() => {
|
|
2215
|
+
const legacyHit = firstEnvValue2(env, apiKeyKeys);
|
|
2216
|
+
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
2217
|
+
})()
|
|
2218
|
+
];
|
|
2219
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
2220
|
+
return sealCredential2({
|
|
2221
|
+
apiKey: winner.value,
|
|
2222
|
+
tier: "disk",
|
|
2223
|
+
source: winner.path,
|
|
2224
|
+
deliberate: false,
|
|
2225
|
+
deprecated: false,
|
|
2226
|
+
diskCandidates: diskPaths,
|
|
2227
|
+
warning
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
const legacy = firstEnvValue2(env, apiKeyKeys);
|
|
2231
|
+
if (legacy) {
|
|
2232
|
+
assertUsableCredential2(name, legacy.key, legacy.value);
|
|
2233
|
+
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
2234
|
+
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
2235
|
+
const sink = options.onDeprecation ?? defaultDeprecationSink2;
|
|
2236
|
+
const notified = deprecationNotified2();
|
|
2237
|
+
if (!notified.has(name)) {
|
|
2238
|
+
notified.add(name);
|
|
2239
|
+
sink(message);
|
|
2240
|
+
}
|
|
2241
|
+
return sealCredential2({
|
|
2242
|
+
apiKey: legacy.value,
|
|
2243
|
+
tier: "legacy-env",
|
|
2244
|
+
source: legacy.key,
|
|
2245
|
+
deliberate: false,
|
|
2246
|
+
deprecated: true,
|
|
2247
|
+
diskCandidates: diskPaths,
|
|
2248
|
+
warning: message
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
return null;
|
|
2252
|
+
}
|
|
2253
|
+
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
2254
|
+
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2255
|
+
function isValidDnsDomain2(value) {
|
|
2256
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN2.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
2257
|
+
return false;
|
|
2258
|
+
}
|
|
2259
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
2260
|
+
}
|
|
2261
|
+
function firstEnv2(env, keys, options = {}) {
|
|
2262
|
+
for (const key of keys) {
|
|
2263
|
+
const raw = env[key];
|
|
2264
|
+
const value = raw?.trim();
|
|
2265
|
+
if (value)
|
|
2266
|
+
return { key, value: options.preserveRaw ? raw : value };
|
|
2267
|
+
}
|
|
2268
|
+
return null;
|
|
2269
|
+
}
|
|
2270
|
+
function firstEnvDefinedKey2(env, keys) {
|
|
2271
|
+
for (const key of keys) {
|
|
2272
|
+
if (env[key] !== undefined)
|
|
2273
|
+
return key;
|
|
2274
|
+
}
|
|
2275
|
+
return null;
|
|
2276
|
+
}
|
|
2277
|
+
function rawAuthority2(value) {
|
|
2278
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
2279
|
+
if (!match)
|
|
2280
|
+
throw new Error("API URL must be absolute.");
|
|
2281
|
+
const afterScheme = value.slice(match[0].length);
|
|
2282
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
2283
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
2284
|
+
if (!authority)
|
|
2285
|
+
throw new Error("API URL must include a hostname.");
|
|
2286
|
+
return authority;
|
|
2287
|
+
}
|
|
2288
|
+
function assertCanonicalPort2(port) {
|
|
2289
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
2290
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2291
|
+
}
|
|
2292
|
+
const numericPort = Number(port);
|
|
2293
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
2294
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
function canonicalAuthorityHostname2(authority) {
|
|
2298
|
+
let rawHostname;
|
|
2299
|
+
if (authority.startsWith("[")) {
|
|
2300
|
+
const closingBracket = authority.indexOf("]");
|
|
2301
|
+
if (closingBracket === -1) {
|
|
2302
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
2303
|
+
}
|
|
2304
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
2305
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
2306
|
+
if (portSuffix) {
|
|
2307
|
+
if (!portSuffix.startsWith(":")) {
|
|
2308
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
2309
|
+
}
|
|
2310
|
+
assertCanonicalPort2(portSuffix.slice(1));
|
|
2311
|
+
}
|
|
2312
|
+
if (isIP2(rawHostname.slice(1, -1)) !== 6) {
|
|
2313
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
2314
|
+
}
|
|
2315
|
+
} else {
|
|
2316
|
+
const firstColon = authority.indexOf(":");
|
|
2317
|
+
const lastColon = authority.lastIndexOf(":");
|
|
2318
|
+
if (firstColon !== lastColon) {
|
|
2319
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
2320
|
+
}
|
|
2321
|
+
if (lastColon !== -1) {
|
|
2322
|
+
const port = authority.slice(lastColon + 1);
|
|
2323
|
+
assertCanonicalPort2(port);
|
|
2324
|
+
rawHostname = authority.slice(0, lastColon);
|
|
2325
|
+
} else {
|
|
2326
|
+
rawHostname = authority;
|
|
2327
|
+
}
|
|
2328
|
+
const ipVersion = isIP2(rawHostname);
|
|
2329
|
+
const numericAddressParts = rawHostname.split(".");
|
|
2330
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
2331
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain2(rawHostname.toLowerCase())) {
|
|
2332
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
return rawHostname.toLowerCase();
|
|
2336
|
+
}
|
|
2337
|
+
function isDeliberateLoopbackHttpAuthority2(authority) {
|
|
2338
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
2339
|
+
}
|
|
2340
|
+
function toV1BaseUrl2(apiUrl) {
|
|
2341
|
+
if (ASCII_CONTROL_PATTERN2.test(apiUrl)) {
|
|
2342
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
2343
|
+
}
|
|
2344
|
+
const input = apiUrl.trim();
|
|
2345
|
+
const authority = rawAuthority2(input);
|
|
2346
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
2347
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
2348
|
+
}
|
|
2349
|
+
const canonicalHostname = canonicalAuthorityHostname2(authority);
|
|
2350
|
+
const url = new URL(input);
|
|
2351
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2352
|
+
throw new Error("API URL must use http or https.");
|
|
2353
|
+
}
|
|
2354
|
+
if (url.username || url.password) {
|
|
2355
|
+
throw new Error("API URL must not include credentials.");
|
|
2356
|
+
}
|
|
2357
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
2358
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
2359
|
+
}
|
|
2360
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
2361
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
2362
|
+
}
|
|
2363
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
2364
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
2365
|
+
}
|
|
2366
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority2(authority)) {
|
|
2367
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
2368
|
+
}
|
|
2369
|
+
if (url.search || url.hash) {
|
|
2370
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
2371
|
+
}
|
|
2372
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
2373
|
+
if (path.endsWith("/v1"))
|
|
2374
|
+
path = path.slice(0, -"/v1".length);
|
|
2375
|
+
url.pathname = `${path}/v1`;
|
|
2376
|
+
return url.toString().replace(/\/+$/, "");
|
|
2377
|
+
}
|
|
2378
|
+
function resolveClientTransport2(name, env = process.env, options = {}) {
|
|
2379
|
+
const keys = clientTransportEnvKeys2(name);
|
|
2380
|
+
const envUrlHit = firstEnv2(env, keys.apiUrlKeys, { preserveRaw: true });
|
|
2381
|
+
const explicitLocalKey = envUrlHit ? null : firstEnvDefinedKey2(env, keys.apiUrlKeys);
|
|
2382
|
+
const diskUrlHit = envUrlHit || explicitLocalKey ? null : appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
2383
|
+
const urlHit = envUrlHit ?? (diskUrlHit ? { key: diskUrlHit.path, value: diskUrlHit.value } : null);
|
|
2384
|
+
const keyHit = firstEnv2(env, keys.apiKeyKeys);
|
|
2385
|
+
const warnings = [];
|
|
2386
|
+
if (!urlHit) {
|
|
2387
|
+
if (explicitLocalKey) {
|
|
2388
|
+
const overriddenPointer = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
2389
|
+
if (overriddenPointer) {
|
|
2390
|
+
warnings.push(`${explicitLocalKey} is defined but blank, which selects the local store. ` + `The server URL in ${overriddenPointer.path} was NOT selected: an explicit blank wins over a disk pointer.`);
|
|
2391
|
+
}
|
|
2392
|
+
return {
|
|
2393
|
+
transport: "sqlite",
|
|
2394
|
+
transportSource: explicitLocalKey,
|
|
2395
|
+
baseUrl: null,
|
|
2396
|
+
apiUrlSource: null,
|
|
2397
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2398
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
2399
|
+
apiKeyTier: null,
|
|
2400
|
+
misconfigured: false,
|
|
2401
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
return {
|
|
2405
|
+
transport: "sqlite",
|
|
2406
|
+
transportSource: "default",
|
|
2407
|
+
baseUrl: null,
|
|
2408
|
+
apiUrlSource: null,
|
|
2409
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2410
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
2411
|
+
apiKeyTier: null,
|
|
2412
|
+
misconfigured: false,
|
|
2413
|
+
warning: null
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
if (diskUrlHit) {
|
|
2417
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${diskUrlHit.path} was used, so this client connects to the server. ` + `Unset the pointer or remove the file to stay on the local store.`);
|
|
2418
|
+
}
|
|
2419
|
+
const credential = resolveCredential2(name, env, options.credentials);
|
|
2420
|
+
if (!credential) {
|
|
2421
|
+
const diskHint = credentialDiskSourcesForMessage2(name, env);
|
|
2422
|
+
warnings.push(`${urlHit.key} selects the HTTP server for '${name}', but no API key could be resolved; ` + `refusing to route and leaving the local sqlite store selected. ` + `Looked for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
2423
|
+
return {
|
|
2424
|
+
transport: "sqlite",
|
|
2425
|
+
transportSource: urlHit.key,
|
|
2426
|
+
baseUrl: null,
|
|
2427
|
+
apiUrlSource: urlHit.key,
|
|
2428
|
+
apiKeyPresent: false,
|
|
2429
|
+
apiKeySource: null,
|
|
2430
|
+
apiKeyTier: null,
|
|
2431
|
+
misconfigured: true,
|
|
2432
|
+
warning: warnings.join(" ")
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
if (credential.warning)
|
|
2436
|
+
warnings.push(credential.warning);
|
|
2437
|
+
const apiUrlSource = urlHit.key;
|
|
2438
|
+
let baseUrl;
|
|
2439
|
+
try {
|
|
2440
|
+
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2443
|
+
warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
|
|
2444
|
+
return {
|
|
2445
|
+
transport: "sqlite",
|
|
2446
|
+
transportSource: urlHit.key,
|
|
2447
|
+
baseUrl: null,
|
|
2448
|
+
apiUrlSource: urlHit.key,
|
|
2449
|
+
apiKeyPresent: true,
|
|
2450
|
+
apiKeySource: credential.source,
|
|
2451
|
+
apiKeyTier: credential.tier,
|
|
2452
|
+
misconfigured: true,
|
|
2453
|
+
warning: warnings.join(" ")
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
return {
|
|
2457
|
+
transport: "http",
|
|
2458
|
+
transportSource: urlHit.key,
|
|
2459
|
+
baseUrl,
|
|
2460
|
+
apiUrlSource,
|
|
2461
|
+
apiKeyPresent: true,
|
|
2462
|
+
apiKeySource: credential.source,
|
|
2463
|
+
apiKeyTier: credential.tier,
|
|
2464
|
+
misconfigured: false,
|
|
2465
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
function credentialDiskSourcesForMessage2(name, env) {
|
|
2469
|
+
const paths = credentialDiskSources2(name, env);
|
|
2470
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
class HasnaHttpError2 extends Error {
|
|
2474
|
+
status;
|
|
2475
|
+
method;
|
|
2476
|
+
path;
|
|
2477
|
+
body;
|
|
2478
|
+
credentialSource;
|
|
2479
|
+
credentialTier;
|
|
2480
|
+
constructor(method, path, status, body, credential) {
|
|
2481
|
+
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
2482
|
+
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
2483
|
+
this.name = "HasnaHttpError";
|
|
2484
|
+
this.status = status;
|
|
2485
|
+
this.method = method;
|
|
2486
|
+
this.path = path;
|
|
2487
|
+
this.body = body;
|
|
2488
|
+
this.credentialSource = credential?.source ?? null;
|
|
2489
|
+
this.credentialTier = credential?.tier ?? null;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
function currentCredential2(name, apiKey) {
|
|
2493
|
+
if (typeof apiKey === "function") {
|
|
2494
|
+
return validateAndSealResolvedCredential2(name, apiKey());
|
|
2495
|
+
}
|
|
2496
|
+
return explicitCredential2(name, apiKey);
|
|
2497
|
+
}
|
|
2498
|
+
function authFailureGuidance2(credential) {
|
|
2499
|
+
const origin = `The API key for this request came from ${credential.source}`;
|
|
2500
|
+
if (credential.deliberate) {
|
|
2501
|
+
const remedy = credential.source === CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2 ? `Fix that provider so it returns the current key, or replace it with resolveCredential() ` + `so diagnostics can name the original source.` : `Rotate that key, or unset the override to use the credential on disk.`;
|
|
2502
|
+
return `${origin} \u2014 a credential you selected deliberately. It was NOT substituted with any other key: ` + `falling back here would authenticate as a different principal than the one you named, which is ` + `exactly the failure an override exists to prevent. ${remedy}`;
|
|
2503
|
+
}
|
|
2504
|
+
if (credential.deprecated) {
|
|
2505
|
+
const target = credential.diskCandidates[0];
|
|
2506
|
+
const remedy = target ? `Write the CURRENT key to ${target} \u2014 that file is re-read on every call, so rotations take ` + `effect immediately and in every shell. Do not simply unset ${credential.source}: nothing was ` + `found on disk, so that would leave this client with no credential at all.` : `This environment has no HOME, so no credential file could be consulted; the disk tier is ` + `unavailable here and there is nothing to fall back to. Set HOME, or supply the key explicitly.`;
|
|
2507
|
+
return `${origin}, a variable in this process's environment \u2014 which is a snapshot taken when the process ` + `started. A STALE SHELL is the most common cause of this error: this shell exported the key before ` + `it was rotated, and will keep sending the old one until it exits. ${remedy}`;
|
|
2508
|
+
}
|
|
2509
|
+
return `${origin}, which was re-read from disk on this very call \u2014 so a stale shell is NOT the cause here. ` + `The stored credential is genuinely being rejected: rotate it, or re-run the fleet key distribution ` + `so this machine gets the current key.`;
|
|
2510
|
+
}
|
|
2511
|
+
var DEFAULT_RETRY_STATUSES2 = [408, 425, 429, 500, 502, 503, 504];
|
|
2512
|
+
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2513
|
+
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2514
|
+
"host",
|
|
2515
|
+
":authority",
|
|
2516
|
+
"forwarded",
|
|
2517
|
+
"x-forwarded-host",
|
|
2518
|
+
"x-original-host"
|
|
2519
|
+
]);
|
|
2520
|
+
function assertNoAuthorityOverrideHeaders2(headers, source) {
|
|
2521
|
+
if (!headers)
|
|
2522
|
+
return;
|
|
2523
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
2524
|
+
if (forbidden) {
|
|
2525
|
+
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
function appendQuery2(path, query) {
|
|
2529
|
+
if (!query)
|
|
2530
|
+
return path;
|
|
2531
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
2532
|
+
if (!(query instanceof URLSearchParams)) {
|
|
2533
|
+
for (const [key, value] of Object.entries(query)) {
|
|
2534
|
+
if (value === null || value === undefined)
|
|
2535
|
+
continue;
|
|
2536
|
+
if (Array.isArray(value)) {
|
|
2537
|
+
for (const v of value)
|
|
2538
|
+
params.append(key, String(v));
|
|
2539
|
+
} else {
|
|
2540
|
+
params.append(key, String(value));
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
const qs = params.toString();
|
|
2545
|
+
if (!qs)
|
|
2546
|
+
return path;
|
|
2547
|
+
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
2548
|
+
}
|
|
2549
|
+
var defaultSleep2 = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2550
|
+
function createHasnaHttpTransport2(options) {
|
|
2551
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2552
|
+
const base = toV1BaseUrl2(options.baseUrl);
|
|
2553
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2554
|
+
const sleep = options.sleepImpl ?? defaultSleep2;
|
|
2555
|
+
const defaultRetry = options.retry;
|
|
2556
|
+
function resolveRetry(callRetry) {
|
|
2557
|
+
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
2558
|
+
if (chosen === false)
|
|
2559
|
+
return null;
|
|
2560
|
+
const r = chosen ?? {};
|
|
2561
|
+
return {
|
|
2562
|
+
retries: r.retries ?? 2,
|
|
2563
|
+
baseDelayMs: r.baseDelayMs ?? 200,
|
|
2564
|
+
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
2565
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES2]
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
async function once(method, rel, url, body, opts, credential) {
|
|
2569
|
+
assertNoAuthorityOverrideHeaders2(options.headers, "transport");
|
|
2570
|
+
assertNoAuthorityOverrideHeaders2(opts.headers, "request");
|
|
2571
|
+
const headers = {
|
|
2572
|
+
"x-api-key": credential.apiKey,
|
|
2573
|
+
Authorization: `Bearer ${credential.apiKey}`,
|
|
2574
|
+
Accept: "application/json",
|
|
2575
|
+
...options.headers ?? {},
|
|
2576
|
+
...opts.headers ?? {}
|
|
2577
|
+
};
|
|
2578
|
+
if (opts.idempotencyKey)
|
|
2579
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
2580
|
+
const init = {
|
|
2581
|
+
method,
|
|
2582
|
+
headers,
|
|
2583
|
+
redirect: "manual"
|
|
2584
|
+
};
|
|
2585
|
+
if (body !== undefined) {
|
|
2586
|
+
headers["Content-Type"] = "application/json";
|
|
2587
|
+
init.body = JSON.stringify(body);
|
|
2588
|
+
}
|
|
2589
|
+
const controller = new AbortController;
|
|
2590
|
+
const onAbort = () => controller.abort();
|
|
2591
|
+
if (opts.signal) {
|
|
2592
|
+
if (opts.signal.aborted)
|
|
2593
|
+
controller.abort();
|
|
2594
|
+
else
|
|
2595
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
2596
|
+
}
|
|
2597
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
2598
|
+
init.signal = controller.signal;
|
|
2599
|
+
let response;
|
|
2600
|
+
try {
|
|
2601
|
+
response = await fetchImpl(url, init);
|
|
2602
|
+
} catch (error) {
|
|
2603
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2604
|
+
if (opts.signal?.aborted)
|
|
2605
|
+
return { ok: false, retryable: false, error: err };
|
|
2606
|
+
return { ok: false, retryable: true, error: err };
|
|
2607
|
+
} finally {
|
|
2608
|
+
clearTimeout(timer);
|
|
2609
|
+
if (opts.signal)
|
|
2610
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
2611
|
+
}
|
|
2612
|
+
const text = await response.text();
|
|
2613
|
+
let parsed = undefined;
|
|
2614
|
+
if (text.length > 0) {
|
|
2615
|
+
try {
|
|
2616
|
+
parsed = JSON.parse(text);
|
|
2617
|
+
} catch {
|
|
2618
|
+
parsed = text;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
if (!response.ok) {
|
|
2622
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2623
|
+
return {
|
|
2624
|
+
ok: false,
|
|
2625
|
+
retryable: false,
|
|
2626
|
+
error: new HasnaHttpError2(method, rel, response.status, parsed)
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
if (response.status === 401 || response.status === 403) {
|
|
2630
|
+
return {
|
|
2631
|
+
ok: false,
|
|
2632
|
+
retryable: false,
|
|
2633
|
+
error: new HasnaHttpError2(method, rel, response.status, parsed, {
|
|
2634
|
+
source: credential.source,
|
|
2635
|
+
tier: credential.tier,
|
|
2636
|
+
guidance: authFailureGuidance2(credential)
|
|
2637
|
+
})
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
const retry = resolveRetry(opts.retry);
|
|
2641
|
+
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
2642
|
+
return { ok: false, retryable, error: new HasnaHttpError2(method, rel, response.status, parsed) };
|
|
2643
|
+
}
|
|
2644
|
+
return { ok: true, value: parsed };
|
|
2645
|
+
}
|
|
2646
|
+
async function request(method, path, body, opts = {}) {
|
|
2647
|
+
const upper = method.toUpperCase();
|
|
2648
|
+
const rel = appendQuery2(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2649
|
+
const url = `${base}${rel}`;
|
|
2650
|
+
const retry = resolveRetry(opts.retry);
|
|
2651
|
+
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
2652
|
+
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2653
|
+
const credential = currentCredential2(options.name, options.apiKey);
|
|
2654
|
+
let last = null;
|
|
2655
|
+
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2656
|
+
const result = await once(upper, rel, url, body, opts, credential);
|
|
2657
|
+
if (result.ok)
|
|
2658
|
+
return result.value;
|
|
2659
|
+
last = result;
|
|
2660
|
+
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
2661
|
+
if (!canRetry)
|
|
2662
|
+
break;
|
|
2663
|
+
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
2664
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
2665
|
+
await sleep(backoff + jitter);
|
|
2666
|
+
}
|
|
2667
|
+
throw last.error;
|
|
2668
|
+
}
|
|
2669
|
+
return {
|
|
2670
|
+
baseUrl: base,
|
|
2671
|
+
request,
|
|
2672
|
+
get: (path, opts) => request("GET", path, undefined, opts),
|
|
2673
|
+
post: (path, body, opts) => request("POST", path, body, opts),
|
|
2674
|
+
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
2675
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
2676
|
+
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
function createClientTransport2(name, env = process.env, overrides) {
|
|
2680
|
+
const credentialOptions = overrides?.credentials;
|
|
2681
|
+
const resolution = resolveClientTransport2(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
|
|
2682
|
+
if (resolution.misconfigured) {
|
|
2683
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
|
|
2684
|
+
}
|
|
2685
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
2686
|
+
return { transport: "sqlite", client: null, resolution };
|
|
2687
|
+
}
|
|
2688
|
+
const credentialProvider = () => {
|
|
2689
|
+
const resolved = resolveCredential2(name, env, credentialOptions);
|
|
2690
|
+
if (!resolved) {
|
|
2691
|
+
throw new Error(`Client for '${name}' resolved to the http transport but no API key is available any more. ` + `Looked at ${credentialDiskSourcesForMessage2(name, env)}, then the environment. ` + `A credential file that was removed after this client was built is the usual cause.`);
|
|
2692
|
+
}
|
|
2693
|
+
return resolved;
|
|
2694
|
+
};
|
|
2695
|
+
return {
|
|
2696
|
+
transport: "http",
|
|
2697
|
+
client: createHasnaHttpTransport2({
|
|
2698
|
+
name,
|
|
2699
|
+
baseUrl: resolution.baseUrl,
|
|
2700
|
+
apiKey: credentialProvider,
|
|
2701
|
+
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2702
|
+
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2703
|
+
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2704
|
+
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2705
|
+
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2706
|
+
}),
|
|
2707
|
+
resolution
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
function resourcePath(resource) {
|
|
2711
|
+
const trimmed = resource.replace(/^\/+|\/+$/g, "");
|
|
2712
|
+
if (!trimmed)
|
|
2713
|
+
throw new Error("resource must be a non-empty path segment");
|
|
2714
|
+
return `/${trimmed}`;
|
|
2715
|
+
}
|
|
2716
|
+
function entityPath(resource, id) {
|
|
2717
|
+
if (id === undefined || id === null || `${id}`.length === 0) {
|
|
2718
|
+
throw new Error("id must be a non-empty string");
|
|
2719
|
+
}
|
|
2720
|
+
return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`;
|
|
2721
|
+
}
|
|
2722
|
+
function newIdempotencyKey() {
|
|
2723
|
+
const g = globalThis;
|
|
2724
|
+
if (g.crypto?.randomUUID)
|
|
2725
|
+
return g.crypto.randomUUID();
|
|
2726
|
+
return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
2727
|
+
}
|
|
2728
|
+
function extractItems(raw) {
|
|
2729
|
+
if (Array.isArray(raw))
|
|
2730
|
+
return raw;
|
|
2731
|
+
if (raw && typeof raw === "object") {
|
|
2732
|
+
const obj = raw;
|
|
2733
|
+
for (const key of ["items", "data", "results", "rows", "records"]) {
|
|
2734
|
+
if (Array.isArray(obj[key]))
|
|
2735
|
+
return obj[key];
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
return [];
|
|
2739
|
+
}
|
|
2740
|
+
function extractTotal(raw) {
|
|
2741
|
+
if (raw && typeof raw === "object") {
|
|
2742
|
+
const obj = raw;
|
|
2743
|
+
for (const key of ["total", "count", "totalCount", "total_count"]) {
|
|
2744
|
+
if (typeof obj[key] === "number")
|
|
2745
|
+
return obj[key];
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
return null;
|
|
2749
|
+
}
|
|
2750
|
+
function extractCursor(raw) {
|
|
2751
|
+
if (raw && typeof raw === "object") {
|
|
2752
|
+
const obj = raw;
|
|
2753
|
+
for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) {
|
|
2754
|
+
if (typeof obj[key] === "string")
|
|
2755
|
+
return obj[key];
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
return null;
|
|
2759
|
+
}
|
|
2760
|
+
function isNotFoundHttpError(error) {
|
|
2761
|
+
return typeof error === "object" && error !== null && error.name === "HasnaHttpError" && error.status === 404;
|
|
1042
2762
|
}
|
|
1043
|
-
function
|
|
1044
|
-
|
|
1045
|
-
|
|
2763
|
+
function createHasnaStorageClient(name, transport) {
|
|
2764
|
+
return {
|
|
2765
|
+
name,
|
|
2766
|
+
baseUrl: transport.baseUrl,
|
|
2767
|
+
transport,
|
|
2768
|
+
async list(resource, options = {}) {
|
|
2769
|
+
const raw = await transport.get(resourcePath(resource), options);
|
|
2770
|
+
return {
|
|
2771
|
+
items: extractItems(raw),
|
|
2772
|
+
total: extractTotal(raw),
|
|
2773
|
+
cursor: extractCursor(raw),
|
|
2774
|
+
raw
|
|
2775
|
+
};
|
|
2776
|
+
},
|
|
2777
|
+
async get(resource, id, options = {}) {
|
|
2778
|
+
try {
|
|
2779
|
+
return await transport.get(entityPath(resource, id), options);
|
|
2780
|
+
} catch (error) {
|
|
2781
|
+
if (isNotFoundHttpError(error))
|
|
2782
|
+
return null;
|
|
2783
|
+
throw error;
|
|
2784
|
+
}
|
|
2785
|
+
},
|
|
2786
|
+
async create(resource, body, options = {}) {
|
|
2787
|
+
const { idempotencyKey, ...rest } = options;
|
|
2788
|
+
return transport.post(resourcePath(resource), body, {
|
|
2789
|
+
...rest,
|
|
2790
|
+
idempotencyKey: idempotencyKey ?? newIdempotencyKey()
|
|
2791
|
+
});
|
|
2792
|
+
},
|
|
2793
|
+
async update(resource, id, patch, options = {}) {
|
|
2794
|
+
const { method = "PATCH", idempotencyKey, ...rest } = options;
|
|
2795
|
+
const call = method === "PUT" ? transport.put : transport.patch;
|
|
2796
|
+
return call(entityPath(resource, id), patch, { ...rest, ...idempotencyKey ? { idempotencyKey } : {} });
|
|
2797
|
+
},
|
|
2798
|
+
async delete(resource, id, options = {}) {
|
|
2799
|
+
try {
|
|
2800
|
+
await transport.del(entityPath(resource, id), undefined, options);
|
|
2801
|
+
} catch (error) {
|
|
2802
|
+
if (isNotFoundHttpError(error))
|
|
2803
|
+
return;
|
|
2804
|
+
throw error;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
1046
2808
|
}
|
|
1047
|
-
function
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
|
|
2809
|
+
function resolveStorageClient(name, env = process.env, overrides) {
|
|
2810
|
+
const wired = createClientTransport2(name, env, overrides);
|
|
2811
|
+
if (wired.transport === "http") {
|
|
2812
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
2813
|
+
}
|
|
2814
|
+
return { transport: "sqlite", client: null };
|
|
1051
2815
|
}
|
|
1052
2816
|
|
|
1053
2817
|
// src/http/client.ts
|
|
1054
|
-
|
|
1055
|
-
function envToken(name) {
|
|
2818
|
+
function envToken3(name) {
|
|
1056
2819
|
return name.toUpperCase().replace(/-/g, "_");
|
|
1057
2820
|
}
|
|
1058
|
-
function
|
|
1059
|
-
|
|
2821
|
+
function envKeys(name) {
|
|
2822
|
+
const token = envToken3(name);
|
|
2823
|
+
return {
|
|
2824
|
+
storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
|
|
2825
|
+
apiUrlKeys: [`HASNA_${token}_API_URL`],
|
|
2826
|
+
apiKeyKeys: [`HASNA_${token}_API_KEY`]
|
|
2827
|
+
};
|
|
1060
2828
|
}
|
|
1061
|
-
function normalizeClientStore(value
|
|
1062
|
-
const normalized =
|
|
2829
|
+
function normalizeClientStore(value) {
|
|
2830
|
+
const normalized = value.trim().toLowerCase();
|
|
1063
2831
|
if (normalized === "sqlite")
|
|
1064
2832
|
return "sqlite";
|
|
1065
2833
|
if (normalized === "http" || normalized === "https")
|
|
1066
2834
|
return "http";
|
|
1067
|
-
if (asRetiredDeploymentMode(value)) {
|
|
1068
|
-
throw retiredDeploymentModeError(value, sourceEnvKey, clientStoreReplacement(replacementEnvKey));
|
|
1069
|
-
}
|
|
1070
2835
|
throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
|
|
1071
2836
|
}
|
|
1072
|
-
function
|
|
1073
|
-
return `http://localhost:8874`;
|
|
1074
|
-
}
|
|
1075
|
-
function envKeys(name) {
|
|
1076
|
-
const token = envToken(name);
|
|
1077
|
-
return {
|
|
1078
|
-
storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
|
|
1079
|
-
retiredModeKeys: [`HASNA_${token}_STORAGE_MODE`, `HASNA_${token}_MODE`, `${token}_STORAGE_MODE`, `${token}_MODE`],
|
|
1080
|
-
apiUrlKeys: [`HASNA_${token}_API_URL`, `${token}_API_URL`],
|
|
1081
|
-
apiKeyKeys: [`HASNA_${token}_API_KEY`, `${token}_API_KEY`]
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
function firstEnv(env, keys) {
|
|
2837
|
+
function firstEnv3(env, keys) {
|
|
1085
2838
|
for (const key of keys) {
|
|
1086
2839
|
const value = env[key]?.trim();
|
|
1087
2840
|
if (value)
|
|
@@ -1089,21 +2842,7 @@ function firstEnv(env, keys) {
|
|
|
1089
2842
|
}
|
|
1090
2843
|
return null;
|
|
1091
2844
|
}
|
|
1092
|
-
function
|
|
1093
|
-
for (const key of keys.retiredModeKeys) {
|
|
1094
|
-
const value = env[key]?.trim();
|
|
1095
|
-
if (!value)
|
|
1096
|
-
continue;
|
|
1097
|
-
const normalized = normalizeModeToken(value);
|
|
1098
|
-
if (SERVER_BACKEND_VALUES.includes(normalized))
|
|
1099
|
-
continue;
|
|
1100
|
-
if (asRetiredDeploymentMode(value)) {
|
|
1101
|
-
throw retiredDeploymentModeError(value, key, clientStoreReplacement(keys.storeKeys[0]));
|
|
1102
|
-
}
|
|
1103
|
-
throw new Error(`${key}=${value} is not a client store. ${key} no longer selects one; ` + `set ${keys.storeKeys[0]}=sqlite or ${keys.storeKeys[0]}=http instead.`);
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
function toV1BaseUrl(apiUrl) {
|
|
2845
|
+
function toV1BaseUrl3(apiUrl) {
|
|
1107
2846
|
const url = new URL(apiUrl);
|
|
1108
2847
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1109
2848
|
throw new Error("API URL must use http or https.");
|
|
@@ -1118,22 +2857,44 @@ function toV1BaseUrl(apiUrl) {
|
|
|
1118
2857
|
}
|
|
1119
2858
|
function resolveTransport(name, env = process.env) {
|
|
1120
2859
|
const keys = envKeys(name);
|
|
1121
|
-
|
|
1122
|
-
const
|
|
1123
|
-
const
|
|
1124
|
-
const keyHit = firstEnv(env, keys.apiKeyKeys);
|
|
2860
|
+
const storeHit = firstEnv3(env, keys.storeKeys);
|
|
2861
|
+
const urlHit = firstEnv3(env, keys.apiUrlKeys);
|
|
2862
|
+
const keyHit = firstEnv3(env, keys.apiKeyKeys);
|
|
1125
2863
|
let requested = "sqlite";
|
|
1126
2864
|
let modeSource = "default";
|
|
1127
2865
|
if (storeHit) {
|
|
1128
|
-
requested = normalizeClientStore(storeHit.value
|
|
2866
|
+
requested = normalizeClientStore(storeHit.value);
|
|
1129
2867
|
modeSource = storeHit.key;
|
|
1130
2868
|
} else if (urlHit && keyHit) {
|
|
1131
2869
|
requested = "http";
|
|
1132
2870
|
modeSource = "auto:api-url+api-key";
|
|
2871
|
+
} else if (urlHit || keyHit) {
|
|
2872
|
+
const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
|
|
2873
|
+
const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
|
|
2874
|
+
return {
|
|
2875
|
+
transport: "sqlite",
|
|
2876
|
+
requested,
|
|
2877
|
+
modeSource,
|
|
2878
|
+
baseUrl: null,
|
|
2879
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2880
|
+
misconfigured: true,
|
|
2881
|
+
warning: `${present} is set but ${missing} is not: the hosted API is only ` + `selected when BOTH are present. Set ${missing}, or unset ${present} to ` + `use the on-box store.`
|
|
2882
|
+
};
|
|
1133
2883
|
}
|
|
1134
2884
|
if (requested === "sqlite") {
|
|
1135
2885
|
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
1136
2886
|
}
|
|
2887
|
+
if (!urlHit) {
|
|
2888
|
+
return {
|
|
2889
|
+
transport: "sqlite",
|
|
2890
|
+
requested,
|
|
2891
|
+
modeSource,
|
|
2892
|
+
baseUrl: null,
|
|
2893
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2894
|
+
misconfigured: true,
|
|
2895
|
+
warning: `${modeSource}=http but no API URL is set (${keys.apiUrlKeys[0]}). Refusing to route to the API.`
|
|
2896
|
+
};
|
|
2897
|
+
}
|
|
1137
2898
|
if (!keyHit) {
|
|
1138
2899
|
return {
|
|
1139
2900
|
transport: "sqlite",
|
|
@@ -1145,10 +2906,10 @@ function resolveTransport(name, env = process.env) {
|
|
|
1145
2906
|
warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
|
|
1146
2907
|
};
|
|
1147
2908
|
}
|
|
1148
|
-
const rawUrl = urlHit
|
|
2909
|
+
const rawUrl = urlHit.value;
|
|
1149
2910
|
let baseUrl;
|
|
1150
2911
|
try {
|
|
1151
|
-
baseUrl =
|
|
2912
|
+
baseUrl = toV1BaseUrl3(rawUrl);
|
|
1152
2913
|
} catch (error) {
|
|
1153
2914
|
const message = error instanceof Error ? error.message : String(error);
|
|
1154
2915
|
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
|
|
@@ -1156,7 +2917,7 @@ function resolveTransport(name, env = process.env) {
|
|
|
1156
2917
|
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
1157
2918
|
}
|
|
1158
2919
|
|
|
1159
|
-
class
|
|
2920
|
+
class HasnaHttpError3 extends Error {
|
|
1160
2921
|
status;
|
|
1161
2922
|
method;
|
|
1162
2923
|
path;
|
|
@@ -1170,7 +2931,7 @@ class HasnaHttpError extends Error {
|
|
|
1170
2931
|
this.body = body;
|
|
1171
2932
|
}
|
|
1172
2933
|
}
|
|
1173
|
-
function
|
|
2934
|
+
function appendQuery3(path, query) {
|
|
1174
2935
|
if (!query)
|
|
1175
2936
|
return path;
|
|
1176
2937
|
const params = new URLSearchParams;
|
|
@@ -1188,12 +2949,12 @@ function appendQuery(path, query) {
|
|
|
1188
2949
|
}
|
|
1189
2950
|
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
1190
2951
|
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
1191
|
-
var
|
|
2952
|
+
var defaultSleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1192
2953
|
function createHttpTransport(options) {
|
|
1193
2954
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1194
2955
|
const base = options.baseUrl.replace(/\/+$/, "");
|
|
1195
2956
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
1196
|
-
const sleep = options.sleepImpl ??
|
|
2957
|
+
const sleep = options.sleepImpl ?? defaultSleep3;
|
|
1197
2958
|
async function once(method, rel, url, body, opts) {
|
|
1198
2959
|
const headers = {
|
|
1199
2960
|
"x-api-key": options.apiKey,
|
|
@@ -1241,13 +3002,13 @@ function createHttpTransport(options) {
|
|
|
1241
3002
|
}
|
|
1242
3003
|
}
|
|
1243
3004
|
if (!response.ok) {
|
|
1244
|
-
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new
|
|
3005
|
+
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new HasnaHttpError3(method, rel, response.status, parsed) };
|
|
1245
3006
|
}
|
|
1246
3007
|
return { ok: true, value: parsed };
|
|
1247
3008
|
}
|
|
1248
3009
|
async function request(method, path, body, opts = {}) {
|
|
1249
3010
|
const upper = method.toUpperCase();
|
|
1250
|
-
const rel =
|
|
3011
|
+
const rel = appendQuery3(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
1251
3012
|
const url = `${base}${rel}`;
|
|
1252
3013
|
const methodRetryable = IDEMPOTENT.has(upper) || Boolean(opts.idempotencyKey);
|
|
1253
3014
|
const maxRetries = opts.retries ?? 2;
|
|
@@ -1279,13 +3040,13 @@ function createHttpTransport(options) {
|
|
|
1279
3040
|
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
1280
3041
|
};
|
|
1281
3042
|
}
|
|
1282
|
-
function
|
|
3043
|
+
function newIdempotencyKey2() {
|
|
1283
3044
|
const g = globalThis;
|
|
1284
3045
|
if (g.crypto?.randomUUID)
|
|
1285
3046
|
return g.crypto.randomUUID();
|
|
1286
3047
|
return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
1287
3048
|
}
|
|
1288
|
-
function
|
|
3049
|
+
function extractItems2(raw, extraKeys = []) {
|
|
1289
3050
|
if (Array.isArray(raw))
|
|
1290
3051
|
return raw;
|
|
1291
3052
|
if (raw && typeof raw === "object") {
|
|
@@ -1306,19 +3067,19 @@ function createStorageClient(name, transport) {
|
|
|
1306
3067
|
transport,
|
|
1307
3068
|
async list(resource, query) {
|
|
1308
3069
|
const raw = await transport.get(rp(resource), { query });
|
|
1309
|
-
return { items:
|
|
3070
|
+
return { items: extractItems2(raw, [resource]), raw };
|
|
1310
3071
|
},
|
|
1311
3072
|
async get(resource, id) {
|
|
1312
3073
|
try {
|
|
1313
3074
|
return await transport.get(ep(resource, id));
|
|
1314
3075
|
} catch (error) {
|
|
1315
|
-
if (error instanceof
|
|
3076
|
+
if (error instanceof HasnaHttpError3 && error.status === 404)
|
|
1316
3077
|
return null;
|
|
1317
3078
|
throw error;
|
|
1318
3079
|
}
|
|
1319
3080
|
},
|
|
1320
3081
|
async create(resource, body, idempotencyKey) {
|
|
1321
|
-
return transport.post(rp(resource), body, { idempotencyKey: idempotencyKey ??
|
|
3082
|
+
return transport.post(rp(resource), body, { idempotencyKey: idempotencyKey ?? newIdempotencyKey2() });
|
|
1322
3083
|
},
|
|
1323
3084
|
async update(resource, id, patch, method = "PATCH") {
|
|
1324
3085
|
const call = method === "PUT" ? transport.put : transport.patch;
|
|
@@ -1328,27 +3089,42 @@ function createStorageClient(name, transport) {
|
|
|
1328
3089
|
try {
|
|
1329
3090
|
await transport.del(ep(resource, id));
|
|
1330
3091
|
} catch (error) {
|
|
1331
|
-
if (error instanceof
|
|
3092
|
+
if (error instanceof HasnaHttpError3 && error.status === 404)
|
|
1332
3093
|
return;
|
|
1333
3094
|
throw error;
|
|
1334
3095
|
}
|
|
1335
3096
|
}
|
|
1336
3097
|
};
|
|
1337
3098
|
}
|
|
1338
|
-
function
|
|
3099
|
+
function resolveStoreClient(name, env = process.env) {
|
|
1339
3100
|
const resolution = resolveTransport(name, env);
|
|
1340
3101
|
if (resolution.misconfigured) {
|
|
3102
|
+
const wired2 = createClientTransport(name, env);
|
|
3103
|
+
if (wired2.transport === "http") {
|
|
3104
|
+
return {
|
|
3105
|
+
transport: "http",
|
|
3106
|
+
client: createHasnaStorageClient(name, wired2.client),
|
|
3107
|
+
resolution: {
|
|
3108
|
+
transport: "http",
|
|
3109
|
+
requested: "http",
|
|
3110
|
+
modeSource: resolution.modeSource === "default" ? "auto:api-url+seam-credential" : resolution.modeSource,
|
|
3111
|
+
baseUrl: wired2.resolution.baseUrl,
|
|
3112
|
+
apiKeyPresent: true,
|
|
3113
|
+
misconfigured: false,
|
|
3114
|
+
warning: null
|
|
3115
|
+
}
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
1341
3118
|
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
|
|
1342
3119
|
}
|
|
1343
3120
|
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
1344
3121
|
return { transport: "sqlite", client: null, resolution };
|
|
1345
3122
|
}
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
if (!apiKey)
|
|
3123
|
+
const wired = createClientTransport(name, env);
|
|
3124
|
+
if (wired.transport !== "http") {
|
|
1349
3125
|
throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
|
|
1350
|
-
|
|
1351
|
-
return { transport: "http", client:
|
|
3126
|
+
}
|
|
3127
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client), resolution };
|
|
1352
3128
|
}
|
|
1353
3129
|
|
|
1354
3130
|
// src/store.ts
|
|
@@ -1431,6 +3207,22 @@ var localStore = {
|
|
|
1431
3207
|
await withLocalStoreReaderLease(() => saveFeedback(input));
|
|
1432
3208
|
}
|
|
1433
3209
|
};
|
|
3210
|
+
async function listResource(client, resource, query) {
|
|
3211
|
+
const raw = await client.transport.get(`/${resource}`, query ? { query } : undefined);
|
|
3212
|
+
return { items: extractEnvelopeItems(raw, resource), raw };
|
|
3213
|
+
}
|
|
3214
|
+
function extractEnvelopeItems(raw, resource) {
|
|
3215
|
+
if (Array.isArray(raw))
|
|
3216
|
+
return raw;
|
|
3217
|
+
if (raw && typeof raw === "object") {
|
|
3218
|
+
const obj = raw;
|
|
3219
|
+
for (const key of [resource, "items", "data", "results", "rows", "records"]) {
|
|
3220
|
+
if (Array.isArray(obj[key]))
|
|
3221
|
+
return obj[key];
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
return [];
|
|
3225
|
+
}
|
|
1434
3226
|
function apiStore(client) {
|
|
1435
3227
|
return {
|
|
1436
3228
|
mode: "http",
|
|
@@ -1438,7 +3230,7 @@ function apiStore(client) {
|
|
|
1438
3230
|
async createRecording(input, idempotencyKey) {
|
|
1439
3231
|
const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID2() : idempotencyKey;
|
|
1440
3232
|
const identity = recordingCreateIdentity(input, keyCandidate, { bindIdempotencyKeyToId: false });
|
|
1441
|
-
const res = await client.create("recordings", identity.input, identity.idempotencyKey);
|
|
3233
|
+
const res = await client.create("recordings", identity.input, { idempotencyKey: identity.idempotencyKey });
|
|
1442
3234
|
return unwrap(res, "recording");
|
|
1443
3235
|
},
|
|
1444
3236
|
async getRecording(id) {
|
|
@@ -1446,7 +3238,7 @@ function apiStore(client) {
|
|
|
1446
3238
|
return res ? unwrap(res, "recording") : null;
|
|
1447
3239
|
},
|
|
1448
3240
|
async listRecordings(filter) {
|
|
1449
|
-
const { items } = await client
|
|
3241
|
+
const { items } = await listResource(client, "recordings", listQuery(filter));
|
|
1450
3242
|
return items;
|
|
1451
3243
|
},
|
|
1452
3244
|
async countRecordings(filter) {
|
|
@@ -1457,7 +3249,7 @@ function apiStore(client) {
|
|
|
1457
3249
|
const seenPageKeys = new Set;
|
|
1458
3250
|
while (pageRequests < maxPageRequests) {
|
|
1459
3251
|
pageRequests += 1;
|
|
1460
|
-
const { items, raw } = await client
|
|
3252
|
+
const { items, raw } = await listResource(client, "recordings", {
|
|
1461
3253
|
...listQuery(filter),
|
|
1462
3254
|
limit: pageLimit,
|
|
1463
3255
|
offset
|
|
@@ -1480,7 +3272,7 @@ function apiStore(client) {
|
|
|
1480
3272
|
throw new Error(`Recordings API exceeded ${maxPageRequests} pages while counting legacy results`);
|
|
1481
3273
|
},
|
|
1482
3274
|
async searchRecordings(query, filter) {
|
|
1483
|
-
const { items } = await client
|
|
3275
|
+
const { items } = await listResource(client, "recordings", listQuery({ ...filter ?? {}, search: query }));
|
|
1484
3276
|
return items;
|
|
1485
3277
|
},
|
|
1486
3278
|
async deleteRecording(id) {
|
|
@@ -1512,7 +3304,7 @@ function apiStore(client) {
|
|
|
1512
3304
|
return res ? unwrap(res, "agent") : null;
|
|
1513
3305
|
},
|
|
1514
3306
|
async listAgents() {
|
|
1515
|
-
const { items } = await client
|
|
3307
|
+
const { items } = await listResource(client, "agents");
|
|
1516
3308
|
return items;
|
|
1517
3309
|
},
|
|
1518
3310
|
async heartbeatAgent(idOrName) {
|
|
@@ -1552,7 +3344,7 @@ function apiStore(client) {
|
|
|
1552
3344
|
return res ? unwrap(res, "project") : null;
|
|
1553
3345
|
},
|
|
1554
3346
|
async listProjects() {
|
|
1555
|
-
const { items } = await client
|
|
3347
|
+
const { items } = await listResource(client, "projects");
|
|
1556
3348
|
return items;
|
|
1557
3349
|
},
|
|
1558
3350
|
async saveFeedback(input) {
|
|
@@ -1575,7 +3367,7 @@ var cached = null;
|
|
|
1575
3367
|
function getStore(env = process.env) {
|
|
1576
3368
|
if (env === process.env && cached)
|
|
1577
3369
|
return cached;
|
|
1578
|
-
const resolved =
|
|
3370
|
+
const resolved = resolveStoreClient(APP, env);
|
|
1579
3371
|
const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
|
|
1580
3372
|
if (env === process.env)
|
|
1581
3373
|
cached = store;
|
|
@@ -1613,14 +3405,13 @@ class EnhancementError extends Error {
|
|
|
1613
3405
|
}
|
|
1614
3406
|
}
|
|
1615
3407
|
export {
|
|
1616
|
-
toV1BaseUrl,
|
|
3408
|
+
toV1BaseUrl3 as toV1BaseUrl,
|
|
1617
3409
|
resolveTransport,
|
|
1618
3410
|
resolveStorageClient,
|
|
1619
3411
|
getStore,
|
|
1620
|
-
defaultApiBaseUrl,
|
|
1621
3412
|
createStorageClient,
|
|
1622
3413
|
createHttpTransport,
|
|
1623
3414
|
__resetStore,
|
|
1624
|
-
HasnaHttpError,
|
|
3415
|
+
HasnaHttpError3 as HasnaHttpError,
|
|
1625
3416
|
APP
|
|
1626
3417
|
};
|