@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/index.js
CHANGED
|
@@ -30,10 +30,14 @@ class RecordingsV1Client {
|
|
|
30
30
|
const url = new URL(this.baseUrl + path);
|
|
31
31
|
if (opts.query) {
|
|
32
32
|
for (const [key, value] of Object.entries(opts.query)) {
|
|
33
|
+
if (value === undefined || value === null)
|
|
34
|
+
continue;
|
|
33
35
|
if (Array.isArray(value)) {
|
|
34
|
-
for (const item of value)
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
for (const item of value) {
|
|
37
|
+
if (item !== undefined && item !== null)
|
|
38
|
+
url.searchParams.append(key, String(item));
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
37
41
|
url.searchParams.set(key, String(value));
|
|
38
42
|
}
|
|
39
43
|
}
|
|
@@ -1184,9 +1188,118 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1184
1188
|
d.query("UPDATE agents SET active_project_id = ?, last_seen_at = ? WHERE id = ?").run(resolvedProjectId, new Date().toISOString(), agent.id);
|
|
1185
1189
|
return getAgent(agent.id, d);
|
|
1186
1190
|
}
|
|
1191
|
+
// package.json
|
|
1192
|
+
var package_default = {
|
|
1193
|
+
name: "@hasna/recordings",
|
|
1194
|
+
version: "0.3.9",
|
|
1195
|
+
type: "module",
|
|
1196
|
+
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
1197
|
+
repository: {
|
|
1198
|
+
type: "git",
|
|
1199
|
+
url: "git+https://github.com/hasna/apps"
|
|
1200
|
+
},
|
|
1201
|
+
main: "dist/index.js",
|
|
1202
|
+
types: "dist/index.d.ts",
|
|
1203
|
+
bin: {
|
|
1204
|
+
recordings: "dist/cli/index.js",
|
|
1205
|
+
"recordings-mcp": "dist/mcp/index.js",
|
|
1206
|
+
"recordings-serve": "dist/server/index.js"
|
|
1207
|
+
},
|
|
1208
|
+
exports: {
|
|
1209
|
+
".": {
|
|
1210
|
+
import: "./dist/index.js",
|
|
1211
|
+
types: "./dist/index.d.ts"
|
|
1212
|
+
},
|
|
1213
|
+
"./storage": {
|
|
1214
|
+
import: "./dist/storage.js",
|
|
1215
|
+
types: "./dist/storage.d.ts"
|
|
1216
|
+
},
|
|
1217
|
+
"./sdk": {
|
|
1218
|
+
import: "./dist/sdk/index.js",
|
|
1219
|
+
types: "./dist/sdk/index.d.ts"
|
|
1220
|
+
}
|
|
1221
|
+
},
|
|
1222
|
+
engines: {
|
|
1223
|
+
bun: ">=1.0.0"
|
|
1224
|
+
},
|
|
1225
|
+
scripts: {
|
|
1226
|
+
clean: "rm -rf dist",
|
|
1227
|
+
build: "bun run clean && bun run build:cli && bun run build:mcp && bun run build:serve && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
1228
|
+
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
1229
|
+
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
1230
|
+
"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",
|
|
1231
|
+
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai",
|
|
1232
|
+
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
1233
|
+
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
1234
|
+
migrate: "bun run scripts/migrate.ts",
|
|
1235
|
+
typecheck: "tsc --noEmit",
|
|
1236
|
+
"typecheck:tcc-contract": "tsc --noEmit -p tsconfig.tcc-contract.json",
|
|
1237
|
+
test: "bun test",
|
|
1238
|
+
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
1239
|
+
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
1240
|
+
"verify:ci-native": "bun scripts/ci-native-build.ts",
|
|
1241
|
+
"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)',
|
|
1242
|
+
"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"',
|
|
1243
|
+
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
1244
|
+
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
1245
|
+
"test:vacuity-battery:vars": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/variable-operands.tsv",
|
|
1246
|
+
"test:vacuity-battery:reorder": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/reorder.tsv",
|
|
1247
|
+
"test:coverage": "bun test --coverage",
|
|
1248
|
+
"dev:cli": "bun run src/cli/index.ts",
|
|
1249
|
+
"desktop:snapshot": "bun run src/cli/index.ts app snapshot",
|
|
1250
|
+
"dev:mcp": "bun run src/mcp/index.ts",
|
|
1251
|
+
"verify:release": "bun run scripts/release-guard.ts",
|
|
1252
|
+
"scan:artifact": "bun run scripts/scan-artifact.ts",
|
|
1253
|
+
"version:set": "bun run scripts/set-version.ts",
|
|
1254
|
+
"version:check": "bun run scripts/set-version.ts --check",
|
|
1255
|
+
prepack: "bun run prepack:platform-gate && bun run version:check && bun run build && bun run verify:release && bun run scan:artifact",
|
|
1256
|
+
"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'`,
|
|
1257
|
+
prepublishOnly: "bun run typecheck && bun run release-suite-gate",
|
|
1258
|
+
"release-suite-gate": "bun run scripts/release-suite-gate.ts",
|
|
1259
|
+
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json"
|
|
1260
|
+
},
|
|
1261
|
+
files: [
|
|
1262
|
+
"dist/",
|
|
1263
|
+
"scripts/",
|
|
1264
|
+
"Dockerfile.package",
|
|
1265
|
+
"bun.lock",
|
|
1266
|
+
"src/native/Recordings/App/",
|
|
1267
|
+
"src/native/Recordings/RecordingsLib/",
|
|
1268
|
+
"src/native/Recordings/RecordingsTests/",
|
|
1269
|
+
"src/native/Recordings/Updater/",
|
|
1270
|
+
"src/native/Recordings/Package.swift",
|
|
1271
|
+
"src/native/Recordings/Package.resolved",
|
|
1272
|
+
"src/native/Recordings/build.sh",
|
|
1273
|
+
"packaging/macos/",
|
|
1274
|
+
"README.md",
|
|
1275
|
+
"LICENSE"
|
|
1276
|
+
],
|
|
1277
|
+
dependencies: {
|
|
1278
|
+
"@hasna/contracts": "0.13.4",
|
|
1279
|
+
"@hasna/events": "0.1.11",
|
|
1280
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
1281
|
+
chalk: "^5.4.1",
|
|
1282
|
+
commander: "^13.1.0",
|
|
1283
|
+
openai: "^5.1.0",
|
|
1284
|
+
pg: "^8.13.3",
|
|
1285
|
+
zod: "^3.24.2"
|
|
1286
|
+
},
|
|
1287
|
+
devDependencies: {
|
|
1288
|
+
"@types/bun": "^1.2.5",
|
|
1289
|
+
"@types/pg": "^8.11.11",
|
|
1290
|
+
"node-api-headers": "1.9.0",
|
|
1291
|
+
typescript: "^5.8.2"
|
|
1292
|
+
},
|
|
1293
|
+
publishConfig: {
|
|
1294
|
+
registry: "https://registry.npmjs.org",
|
|
1295
|
+
access: "public"
|
|
1296
|
+
},
|
|
1297
|
+
license: "Apache-2.0",
|
|
1298
|
+
author: "Hasna <andrei@hasna.com>"
|
|
1299
|
+
};
|
|
1187
1300
|
|
|
1188
1301
|
// src/version.ts
|
|
1189
|
-
var VERSION =
|
|
1302
|
+
var VERSION = package_default.version;
|
|
1190
1303
|
|
|
1191
1304
|
// src/db/feedback.ts
|
|
1192
1305
|
function saveFeedback(input) {
|
|
@@ -1194,59 +1307,207 @@ function saveFeedback(input) {
|
|
|
1194
1307
|
db.query("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)").run(input.message, input.email ?? null, input.category ?? "general", input.version ?? VERSION);
|
|
1195
1308
|
}
|
|
1196
1309
|
|
|
1197
|
-
//
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
"
|
|
1203
|
-
"hybrid"
|
|
1204
|
-
];
|
|
1205
|
-
function normalizeModeToken(value) {
|
|
1206
|
-
return value.trim().toLowerCase().replace(/-/g, "_");
|
|
1310
|
+
// ../contracts/dist/client/transport.js
|
|
1311
|
+
import { isIP } from "net";
|
|
1312
|
+
import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
1313
|
+
import { join as join3 } from "path";
|
|
1314
|
+
function envToken(name) {
|
|
1315
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
1207
1316
|
}
|
|
1208
|
-
function
|
|
1209
|
-
const
|
|
1210
|
-
return
|
|
1317
|
+
function clientTransportEnvKeys(name) {
|
|
1318
|
+
const envSegment = envToken(name);
|
|
1319
|
+
return {
|
|
1320
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
1321
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
1322
|
+
};
|
|
1211
1323
|
}
|
|
1212
|
-
function
|
|
1213
|
-
|
|
1214
|
-
const value = mode === "local" ? replacement.onBox : replacement.offBox;
|
|
1215
|
-
return new Error(`${sourceEnvKey}=${rawValue} names a deployment mode, and deployment modes are removed: ` + `${RETIRED_DEPLOYMENT_MODES.join(" | ")} no longer select anything. ` + `Set ${replacement.envKey}=${value} instead ` + `(${replacement.envKey} takes ${replacement.onBox} or ${replacement.offBox}).`);
|
|
1324
|
+
function credentialOverrideEnvKey(name) {
|
|
1325
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
1216
1326
|
}
|
|
1327
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
1217
1328
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1329
|
+
class CredentialResolutionError extends Error {
|
|
1330
|
+
appName;
|
|
1331
|
+
attempted;
|
|
1332
|
+
constructor(appName, message, attempted) {
|
|
1333
|
+
super(message);
|
|
1334
|
+
this.name = "CredentialResolutionError";
|
|
1335
|
+
this.appName = appName;
|
|
1336
|
+
this.attempted = attempted;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
var HASNA_STATE_DIR = ".hasna";
|
|
1340
|
+
var FLEET_CREDENTIAL_DIR = "cloud";
|
|
1341
|
+
var CONFIG_DIR = ".config";
|
|
1342
|
+
var CONFIG_NAMESPACE = "hasna";
|
|
1343
|
+
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
1344
|
+
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1345
|
+
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
1346
|
+
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
1347
|
+
function homeDir(env) {
|
|
1348
|
+
const home = env.HOME?.trim();
|
|
1349
|
+
return home ? home : null;
|
|
1350
|
+
}
|
|
1351
|
+
function credentialDiskSources(name, env) {
|
|
1352
|
+
return profileDiskSources(name, env, null);
|
|
1353
|
+
}
|
|
1354
|
+
function profileDiskSources(name, env, profile) {
|
|
1355
|
+
const home = homeDir(env);
|
|
1356
|
+
if (!home || !SAFE_APP_SLUG.test(name))
|
|
1357
|
+
return [];
|
|
1358
|
+
const stem = profile ? `${name}.${profile}` : name;
|
|
1359
|
+
const configStem = profile ? `${name}-${profile}` : name;
|
|
1360
|
+
return [
|
|
1361
|
+
join3(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
|
|
1362
|
+
join3(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
|
|
1363
|
+
];
|
|
1364
|
+
}
|
|
1365
|
+
function parseEnvFile(text) {
|
|
1366
|
+
const values = new Map;
|
|
1367
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1368
|
+
const line = rawLine.trim();
|
|
1369
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
1370
|
+
continue;
|
|
1371
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
1372
|
+
const equals = withoutExport.indexOf("=");
|
|
1373
|
+
if (equals <= 0)
|
|
1374
|
+
continue;
|
|
1375
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
1376
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1377
|
+
continue;
|
|
1378
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
1379
|
+
const quote = value[0];
|
|
1380
|
+
if (quote === '"' || quote === "'") {
|
|
1381
|
+
if (value.length < 2 || !value.endsWith(quote))
|
|
1382
|
+
continue;
|
|
1383
|
+
value = value.slice(1, -1);
|
|
1384
|
+
}
|
|
1385
|
+
if (value.length === 0)
|
|
1386
|
+
continue;
|
|
1387
|
+
values.set(key, value);
|
|
1388
|
+
}
|
|
1389
|
+
return values;
|
|
1222
1390
|
}
|
|
1223
|
-
function
|
|
1224
|
-
|
|
1391
|
+
function readAppConfigFile(path) {
|
|
1392
|
+
let text;
|
|
1393
|
+
try {
|
|
1394
|
+
const stats = statSync2(path);
|
|
1395
|
+
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
1396
|
+
return null;
|
|
1397
|
+
text = readFileSync2(path, "utf8");
|
|
1398
|
+
} catch {
|
|
1399
|
+
return null;
|
|
1400
|
+
}
|
|
1401
|
+
return parseEnvFile(text);
|
|
1225
1402
|
}
|
|
1226
|
-
function
|
|
1227
|
-
const
|
|
1228
|
-
if (
|
|
1229
|
-
return
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1403
|
+
function readCredentialFile(path, apiKeyKeys) {
|
|
1404
|
+
const values = readAppConfigFile(path);
|
|
1405
|
+
if (!values)
|
|
1406
|
+
return null;
|
|
1407
|
+
for (const key of apiKeyKeys) {
|
|
1408
|
+
const value = values.get(key)?.trim();
|
|
1409
|
+
if (value)
|
|
1410
|
+
return value;
|
|
1234
1411
|
}
|
|
1235
|
-
|
|
1412
|
+
return null;
|
|
1236
1413
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1414
|
+
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
1415
|
+
function appConfigDiskValue(name, env, keys) {
|
|
1416
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
|
|
1417
|
+
if (wanted.length === 0)
|
|
1418
|
+
return null;
|
|
1419
|
+
for (const path of credentialDiskSources(name, env)) {
|
|
1420
|
+
const values = readAppConfigFile(path);
|
|
1421
|
+
if (!values)
|
|
1422
|
+
continue;
|
|
1423
|
+
for (const key of wanted) {
|
|
1424
|
+
const value = values.get(key)?.trim();
|
|
1425
|
+
if (value)
|
|
1426
|
+
return { key, value, path };
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return null;
|
|
1239
1430
|
}
|
|
1240
|
-
function
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1431
|
+
function assertUsableCredential(appName, source, value) {
|
|
1432
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
1433
|
+
return;
|
|
1434
|
+
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]);
|
|
1435
|
+
}
|
|
1436
|
+
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
1437
|
+
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
1438
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
1439
|
+
function sealCredential(fields) {
|
|
1440
|
+
const { apiKey } = fields;
|
|
1441
|
+
const visible = {
|
|
1442
|
+
tier: fields.tier,
|
|
1443
|
+
source: fields.source,
|
|
1444
|
+
deliberate: fields.deliberate,
|
|
1445
|
+
deprecated: fields.deprecated,
|
|
1446
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
1447
|
+
warning: fields.warning
|
|
1247
1448
|
};
|
|
1449
|
+
const sealed = { ...visible };
|
|
1450
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
1451
|
+
value: apiKey,
|
|
1452
|
+
enumerable: false,
|
|
1453
|
+
writable: false,
|
|
1454
|
+
configurable: false
|
|
1455
|
+
});
|
|
1456
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
1457
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
1458
|
+
enumerable: false,
|
|
1459
|
+
writable: false,
|
|
1460
|
+
configurable: false
|
|
1461
|
+
});
|
|
1462
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
1463
|
+
value: true,
|
|
1464
|
+
enumerable: false,
|
|
1465
|
+
writable: false,
|
|
1466
|
+
configurable: false
|
|
1467
|
+
});
|
|
1468
|
+
return Object.freeze(sealed);
|
|
1469
|
+
}
|
|
1470
|
+
function isSealedCredential(credential) {
|
|
1471
|
+
return credential[CREDENTIAL_SEAL] === true;
|
|
1472
|
+
}
|
|
1473
|
+
function explicitCredential(appName, apiKey) {
|
|
1474
|
+
const source = "explicit apiKey option";
|
|
1475
|
+
assertUsableCredential(appName, source, apiKey);
|
|
1476
|
+
return sealCredential({
|
|
1477
|
+
apiKey,
|
|
1478
|
+
tier: "argument",
|
|
1479
|
+
source,
|
|
1480
|
+
deliberate: true,
|
|
1481
|
+
deprecated: false,
|
|
1482
|
+
diskCandidates: [],
|
|
1483
|
+
warning: null
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
1487
|
+
const apiKey = credential.apiKey;
|
|
1488
|
+
assertUsableCredential(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
1489
|
+
if (!isSealedCredential(credential)) {
|
|
1490
|
+
return sealCredential({
|
|
1491
|
+
apiKey,
|
|
1492
|
+
tier: "argument",
|
|
1493
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
1494
|
+
deliberate: true,
|
|
1495
|
+
deprecated: false,
|
|
1496
|
+
diskCandidates: [],
|
|
1497
|
+
warning: null
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
return sealCredential({
|
|
1501
|
+
apiKey,
|
|
1502
|
+
tier: credential.tier,
|
|
1503
|
+
source: credential.source,
|
|
1504
|
+
deliberate: credential.deliberate,
|
|
1505
|
+
deprecated: credential.deprecated,
|
|
1506
|
+
diskCandidates: credential.diskCandidates,
|
|
1507
|
+
warning: credential.warning
|
|
1508
|
+
});
|
|
1248
1509
|
}
|
|
1249
|
-
function
|
|
1510
|
+
function firstEnvValue(env, keys) {
|
|
1250
1511
|
for (const key of keys) {
|
|
1251
1512
|
const value = env[key]?.trim();
|
|
1252
1513
|
if (value)
|
|
@@ -1254,71 +1515,343 @@ function firstEnv(env, keys) {
|
|
|
1254
1515
|
}
|
|
1255
1516
|
return null;
|
|
1256
1517
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1518
|
+
var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
1519
|
+
function deprecationNotified() {
|
|
1520
|
+
const host = globalThis;
|
|
1521
|
+
const existing = host[DEPRECATION_REGISTRY];
|
|
1522
|
+
if (existing instanceof Set)
|
|
1523
|
+
return existing;
|
|
1524
|
+
const created = new Set;
|
|
1525
|
+
host[DEPRECATION_REGISTRY] = created;
|
|
1526
|
+
return created;
|
|
1527
|
+
}
|
|
1528
|
+
function defaultDeprecationSink(message) {
|
|
1529
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
1530
|
+
process.stderr.write(`${message}
|
|
1531
|
+
`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
function resolveCredential(name, env, options = {}) {
|
|
1535
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
1536
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
1537
|
+
const explicitKey = options.apiKey?.trim();
|
|
1538
|
+
if (explicitKey) {
|
|
1539
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
1540
|
+
return sealCredential({
|
|
1541
|
+
apiKey: explicitKey,
|
|
1542
|
+
tier: "argument",
|
|
1543
|
+
source: "explicit apiKey argument",
|
|
1544
|
+
deliberate: true,
|
|
1545
|
+
deprecated: false,
|
|
1546
|
+
diskCandidates: diskPaths,
|
|
1547
|
+
warning: null
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
1551
|
+
const overrideRaw = env[overrideKeyName];
|
|
1552
|
+
if (overrideRaw !== undefined) {
|
|
1553
|
+
const override = overrideRaw.trim();
|
|
1554
|
+
if (!override) {
|
|
1555
|
+
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]);
|
|
1556
|
+
}
|
|
1557
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
1558
|
+
return sealCredential({
|
|
1559
|
+
apiKey: override,
|
|
1560
|
+
tier: "override",
|
|
1561
|
+
source: overrideKeyName,
|
|
1562
|
+
deliberate: true,
|
|
1563
|
+
deprecated: false,
|
|
1564
|
+
diskCandidates: diskPaths,
|
|
1565
|
+
warning: null
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
|
|
1569
|
+
if (profile) {
|
|
1570
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
1571
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
1572
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
1573
|
+
}
|
|
1574
|
+
const paths = profileDiskSources(name, env, profile);
|
|
1575
|
+
for (const path of paths) {
|
|
1576
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
1577
|
+
if (value) {
|
|
1578
|
+
assertUsableCredential(name, path, value);
|
|
1579
|
+
return sealCredential({
|
|
1580
|
+
apiKey: value,
|
|
1581
|
+
tier: "profile",
|
|
1582
|
+
source: path,
|
|
1583
|
+
deliberate: true,
|
|
1584
|
+
deprecated: false,
|
|
1585
|
+
diskCandidates: paths,
|
|
1586
|
+
warning: null
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
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);
|
|
1591
|
+
}
|
|
1592
|
+
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
1593
|
+
if (diskHits.length > 0) {
|
|
1594
|
+
const winner = diskHits[0];
|
|
1595
|
+
assertUsableCredential(name, winner.path, winner.value);
|
|
1596
|
+
const divergentSources = [
|
|
1597
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
1598
|
+
...(() => {
|
|
1599
|
+
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
1600
|
+
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
1601
|
+
})()
|
|
1602
|
+
];
|
|
1603
|
+
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;
|
|
1604
|
+
return sealCredential({
|
|
1605
|
+
apiKey: winner.value,
|
|
1606
|
+
tier: "disk",
|
|
1607
|
+
source: winner.path,
|
|
1608
|
+
deliberate: false,
|
|
1609
|
+
deprecated: false,
|
|
1610
|
+
diskCandidates: diskPaths,
|
|
1611
|
+
warning
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
const legacy = firstEnvValue(env, apiKeyKeys);
|
|
1615
|
+
if (legacy) {
|
|
1616
|
+
assertUsableCredential(name, legacy.key, legacy.value);
|
|
1617
|
+
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.`;
|
|
1618
|
+
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}`;
|
|
1619
|
+
const sink = options.onDeprecation ?? defaultDeprecationSink;
|
|
1620
|
+
const notified = deprecationNotified();
|
|
1621
|
+
if (!notified.has(name)) {
|
|
1622
|
+
notified.add(name);
|
|
1623
|
+
sink(message);
|
|
1624
|
+
}
|
|
1625
|
+
return sealCredential({
|
|
1626
|
+
apiKey: legacy.value,
|
|
1627
|
+
tier: "legacy-env",
|
|
1628
|
+
source: legacy.key,
|
|
1629
|
+
deliberate: false,
|
|
1630
|
+
deprecated: true,
|
|
1631
|
+
diskCandidates: diskPaths,
|
|
1632
|
+
warning: message
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
1638
|
+
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1639
|
+
function isValidDnsDomain(value) {
|
|
1640
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
1644
|
+
}
|
|
1645
|
+
function firstEnv(env, keys, options = {}) {
|
|
1646
|
+
for (const key of keys) {
|
|
1647
|
+
const raw = env[key];
|
|
1648
|
+
const value = raw?.trim();
|
|
1649
|
+
if (value)
|
|
1650
|
+
return { key, value: options.preserveRaw ? raw : value };
|
|
1651
|
+
}
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
function firstEnvDefinedKey(env, keys) {
|
|
1655
|
+
for (const key of keys) {
|
|
1656
|
+
if (env[key] !== undefined)
|
|
1657
|
+
return key;
|
|
1658
|
+
}
|
|
1659
|
+
return null;
|
|
1660
|
+
}
|
|
1661
|
+
function rawAuthority(value) {
|
|
1662
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
1663
|
+
if (!match)
|
|
1664
|
+
throw new Error("API URL must be absolute.");
|
|
1665
|
+
const afterScheme = value.slice(match[0].length);
|
|
1666
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
1667
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
1668
|
+
if (!authority)
|
|
1669
|
+
throw new Error("API URL must include a hostname.");
|
|
1670
|
+
return authority;
|
|
1671
|
+
}
|
|
1672
|
+
function assertCanonicalPort(port) {
|
|
1673
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
1674
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
1675
|
+
}
|
|
1676
|
+
const numericPort = Number(port);
|
|
1677
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
1678
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
function canonicalAuthorityHostname(authority) {
|
|
1682
|
+
let rawHostname;
|
|
1683
|
+
if (authority.startsWith("[")) {
|
|
1684
|
+
const closingBracket = authority.indexOf("]");
|
|
1685
|
+
if (closingBracket === -1) {
|
|
1686
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
1687
|
+
}
|
|
1688
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
1689
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
1690
|
+
if (portSuffix) {
|
|
1691
|
+
if (!portSuffix.startsWith(":")) {
|
|
1692
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
1693
|
+
}
|
|
1694
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
1695
|
+
}
|
|
1696
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
1697
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
1698
|
+
}
|
|
1699
|
+
} else {
|
|
1700
|
+
const firstColon = authority.indexOf(":");
|
|
1701
|
+
const lastColon = authority.lastIndexOf(":");
|
|
1702
|
+
if (firstColon !== lastColon) {
|
|
1703
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
1704
|
+
}
|
|
1705
|
+
if (lastColon !== -1) {
|
|
1706
|
+
const port = authority.slice(lastColon + 1);
|
|
1707
|
+
assertCanonicalPort(port);
|
|
1708
|
+
rawHostname = authority.slice(0, lastColon);
|
|
1709
|
+
} else {
|
|
1710
|
+
rawHostname = authority;
|
|
1711
|
+
}
|
|
1712
|
+
const ipVersion = isIP(rawHostname);
|
|
1713
|
+
const numericAddressParts = rawHostname.split(".");
|
|
1714
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
1715
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
1716
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
1267
1717
|
}
|
|
1268
|
-
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.`);
|
|
1269
1718
|
}
|
|
1719
|
+
return rawHostname.toLowerCase();
|
|
1720
|
+
}
|
|
1721
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
1722
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
1270
1723
|
}
|
|
1271
1724
|
function toV1BaseUrl(apiUrl) {
|
|
1272
|
-
|
|
1725
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
1726
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
1727
|
+
}
|
|
1728
|
+
const input = apiUrl.trim();
|
|
1729
|
+
const authority = rawAuthority(input);
|
|
1730
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
1731
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
1732
|
+
}
|
|
1733
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
1734
|
+
const url = new URL(input);
|
|
1273
1735
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1274
1736
|
throw new Error("API URL must use http or https.");
|
|
1275
1737
|
}
|
|
1738
|
+
if (url.username || url.password) {
|
|
1739
|
+
throw new Error("API URL must not include credentials.");
|
|
1740
|
+
}
|
|
1741
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
1742
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
1743
|
+
}
|
|
1744
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
1745
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
1746
|
+
}
|
|
1747
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
1748
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
1749
|
+
}
|
|
1750
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
1751
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
1752
|
+
}
|
|
1753
|
+
if (url.search || url.hash) {
|
|
1754
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
1755
|
+
}
|
|
1276
1756
|
let path = url.pathname.replace(/\/+$/, "");
|
|
1277
1757
|
if (path.endsWith("/v1"))
|
|
1278
1758
|
path = path.slice(0, -"/v1".length);
|
|
1279
1759
|
url.pathname = `${path}/v1`;
|
|
1280
|
-
url.search = "";
|
|
1281
|
-
url.hash = "";
|
|
1282
1760
|
return url.toString().replace(/\/+$/, "");
|
|
1283
1761
|
}
|
|
1284
|
-
function
|
|
1285
|
-
const keys =
|
|
1286
|
-
|
|
1287
|
-
const
|
|
1288
|
-
const
|
|
1762
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
1763
|
+
const keys = clientTransportEnvKeys(name);
|
|
1764
|
+
const envUrlHit = firstEnv(env, keys.apiUrlKeys, { preserveRaw: true });
|
|
1765
|
+
const explicitLocalKey = envUrlHit ? null : firstEnvDefinedKey(env, keys.apiUrlKeys);
|
|
1766
|
+
const diskUrlHit = envUrlHit || explicitLocalKey ? null : appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
1767
|
+
const urlHit = envUrlHit ?? (diskUrlHit ? { key: diskUrlHit.path, value: diskUrlHit.value } : null);
|
|
1289
1768
|
const keyHit = firstEnv(env, keys.apiKeyKeys);
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1769
|
+
const warnings = [];
|
|
1770
|
+
if (!urlHit) {
|
|
1771
|
+
if (explicitLocalKey) {
|
|
1772
|
+
const overriddenPointer = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
1773
|
+
if (overriddenPointer) {
|
|
1774
|
+
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.`);
|
|
1775
|
+
}
|
|
1776
|
+
return {
|
|
1777
|
+
transport: "sqlite",
|
|
1778
|
+
transportSource: explicitLocalKey,
|
|
1779
|
+
baseUrl: null,
|
|
1780
|
+
apiUrlSource: null,
|
|
1781
|
+
apiKeyPresent: Boolean(keyHit),
|
|
1782
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
1783
|
+
apiKeyTier: null,
|
|
1784
|
+
misconfigured: false,
|
|
1785
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
transport: "sqlite",
|
|
1790
|
+
transportSource: "default",
|
|
1791
|
+
baseUrl: null,
|
|
1792
|
+
apiUrlSource: null,
|
|
1793
|
+
apiKeyPresent: Boolean(keyHit),
|
|
1794
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
1795
|
+
apiKeyTier: null,
|
|
1796
|
+
misconfigured: false,
|
|
1797
|
+
warning: null
|
|
1798
|
+
};
|
|
1298
1799
|
}
|
|
1299
|
-
if (
|
|
1300
|
-
|
|
1800
|
+
if (diskUrlHit) {
|
|
1801
|
+
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.`);
|
|
1301
1802
|
}
|
|
1302
|
-
|
|
1803
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
1804
|
+
if (!credential) {
|
|
1805
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
1806
|
+
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.`);
|
|
1303
1807
|
return {
|
|
1304
1808
|
transport: "sqlite",
|
|
1305
|
-
|
|
1306
|
-
modeSource,
|
|
1809
|
+
transportSource: urlHit.key,
|
|
1307
1810
|
baseUrl: null,
|
|
1811
|
+
apiUrlSource: urlHit.key,
|
|
1308
1812
|
apiKeyPresent: false,
|
|
1813
|
+
apiKeySource: null,
|
|
1814
|
+
apiKeyTier: null,
|
|
1309
1815
|
misconfigured: true,
|
|
1310
|
-
warning:
|
|
1816
|
+
warning: warnings.join(" ")
|
|
1311
1817
|
};
|
|
1312
1818
|
}
|
|
1313
|
-
|
|
1819
|
+
if (credential.warning)
|
|
1820
|
+
warnings.push(credential.warning);
|
|
1821
|
+
const apiUrlSource = urlHit.key;
|
|
1314
1822
|
let baseUrl;
|
|
1315
1823
|
try {
|
|
1316
|
-
baseUrl = toV1BaseUrl(
|
|
1824
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
1317
1825
|
} catch (error) {
|
|
1318
1826
|
const message = error instanceof Error ? error.message : String(error);
|
|
1319
|
-
|
|
1827
|
+
warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
|
|
1828
|
+
return {
|
|
1829
|
+
transport: "sqlite",
|
|
1830
|
+
transportSource: urlHit.key,
|
|
1831
|
+
baseUrl: null,
|
|
1832
|
+
apiUrlSource: urlHit.key,
|
|
1833
|
+
apiKeyPresent: true,
|
|
1834
|
+
apiKeySource: credential.source,
|
|
1835
|
+
apiKeyTier: credential.tier,
|
|
1836
|
+
misconfigured: true,
|
|
1837
|
+
warning: warnings.join(" ")
|
|
1838
|
+
};
|
|
1320
1839
|
}
|
|
1321
|
-
return {
|
|
1840
|
+
return {
|
|
1841
|
+
transport: "http",
|
|
1842
|
+
transportSource: urlHit.key,
|
|
1843
|
+
baseUrl,
|
|
1844
|
+
apiUrlSource,
|
|
1845
|
+
apiKeyPresent: true,
|
|
1846
|
+
apiKeySource: credential.source,
|
|
1847
|
+
apiKeyTier: credential.tier,
|
|
1848
|
+
misconfigured: false,
|
|
1849
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
1853
|
+
const paths = credentialDiskSources(name, env);
|
|
1854
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
1322
1855
|
}
|
|
1323
1856
|
|
|
1324
1857
|
class HasnaHttpError extends Error {
|
|
@@ -1326,49 +1859,113 @@ class HasnaHttpError extends Error {
|
|
|
1326
1859
|
method;
|
|
1327
1860
|
path;
|
|
1328
1861
|
body;
|
|
1329
|
-
|
|
1330
|
-
|
|
1862
|
+
credentialSource;
|
|
1863
|
+
credentialTier;
|
|
1864
|
+
constructor(method, path, status, body, credential) {
|
|
1865
|
+
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
1866
|
+
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
1331
1867
|
this.name = "HasnaHttpError";
|
|
1332
1868
|
this.status = status;
|
|
1333
1869
|
this.method = method;
|
|
1334
1870
|
this.path = path;
|
|
1335
1871
|
this.body = body;
|
|
1872
|
+
this.credentialSource = credential?.source ?? null;
|
|
1873
|
+
this.credentialTier = credential?.tier ?? null;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
function currentCredential(name, apiKey) {
|
|
1877
|
+
if (typeof apiKey === "function") {
|
|
1878
|
+
return validateAndSealResolvedCredential(name, apiKey());
|
|
1879
|
+
}
|
|
1880
|
+
return explicitCredential(name, apiKey);
|
|
1881
|
+
}
|
|
1882
|
+
function authFailureGuidance(credential) {
|
|
1883
|
+
const origin = `The API key for this request came from ${credential.source}`;
|
|
1884
|
+
if (credential.deliberate) {
|
|
1885
|
+
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.`;
|
|
1886
|
+
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}`;
|
|
1887
|
+
}
|
|
1888
|
+
if (credential.deprecated) {
|
|
1889
|
+
const target = credential.diskCandidates[0];
|
|
1890
|
+
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.`;
|
|
1891
|
+
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}`;
|
|
1892
|
+
}
|
|
1893
|
+
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.`;
|
|
1894
|
+
}
|
|
1895
|
+
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
1896
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
1897
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
1898
|
+
"host",
|
|
1899
|
+
":authority",
|
|
1900
|
+
"forwarded",
|
|
1901
|
+
"x-forwarded-host",
|
|
1902
|
+
"x-original-host"
|
|
1903
|
+
]);
|
|
1904
|
+
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
1905
|
+
if (!headers)
|
|
1906
|
+
return;
|
|
1907
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS.has(name.trim().toLowerCase()));
|
|
1908
|
+
if (forbidden) {
|
|
1909
|
+
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
1336
1910
|
}
|
|
1337
1911
|
}
|
|
1338
1912
|
function appendQuery(path, query) {
|
|
1339
1913
|
if (!query)
|
|
1340
1914
|
return path;
|
|
1341
|
-
const params = new URLSearchParams;
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1915
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
1916
|
+
if (!(query instanceof URLSearchParams)) {
|
|
1917
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1918
|
+
if (value === null || value === undefined)
|
|
1919
|
+
continue;
|
|
1920
|
+
if (Array.isArray(value)) {
|
|
1921
|
+
for (const v of value)
|
|
1922
|
+
params.append(key, String(v));
|
|
1923
|
+
} else {
|
|
1924
|
+
params.append(key, String(value));
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1350
1927
|
}
|
|
1351
1928
|
const qs = params.toString();
|
|
1352
|
-
|
|
1929
|
+
if (!qs)
|
|
1930
|
+
return path;
|
|
1931
|
+
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
1353
1932
|
}
|
|
1354
|
-
var
|
|
1355
|
-
|
|
1356
|
-
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1357
|
-
function createHttpTransport(options) {
|
|
1933
|
+
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1934
|
+
function createHasnaHttpTransport(options) {
|
|
1358
1935
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1359
|
-
const base = options.baseUrl
|
|
1936
|
+
const base = toV1BaseUrl(options.baseUrl);
|
|
1360
1937
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
1361
1938
|
const sleep = options.sleepImpl ?? defaultSleep;
|
|
1362
|
-
|
|
1939
|
+
const defaultRetry = options.retry;
|
|
1940
|
+
function resolveRetry(callRetry) {
|
|
1941
|
+
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
1942
|
+
if (chosen === false)
|
|
1943
|
+
return null;
|
|
1944
|
+
const r = chosen ?? {};
|
|
1945
|
+
return {
|
|
1946
|
+
retries: r.retries ?? 2,
|
|
1947
|
+
baseDelayMs: r.baseDelayMs ?? 200,
|
|
1948
|
+
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
1949
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
async function once(method, rel, url, body, opts, credential) {
|
|
1953
|
+
assertNoAuthorityOverrideHeaders(options.headers, "transport");
|
|
1954
|
+
assertNoAuthorityOverrideHeaders(opts.headers, "request");
|
|
1363
1955
|
const headers = {
|
|
1364
|
-
"x-api-key":
|
|
1365
|
-
Authorization: `Bearer ${
|
|
1956
|
+
"x-api-key": credential.apiKey,
|
|
1957
|
+
Authorization: `Bearer ${credential.apiKey}`,
|
|
1366
1958
|
Accept: "application/json",
|
|
1959
|
+
...options.headers ?? {},
|
|
1367
1960
|
...opts.headers ?? {}
|
|
1368
1961
|
};
|
|
1369
1962
|
if (opts.idempotencyKey)
|
|
1370
1963
|
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
1371
|
-
const init = {
|
|
1964
|
+
const init = {
|
|
1965
|
+
method,
|
|
1966
|
+
headers,
|
|
1967
|
+
redirect: "manual"
|
|
1968
|
+
};
|
|
1372
1969
|
if (body !== undefined) {
|
|
1373
1970
|
headers["Content-Type"] = "application/json";
|
|
1374
1971
|
init.body = JSON.stringify(body);
|
|
@@ -1406,7 +2003,27 @@ function createHttpTransport(options) {
|
|
|
1406
2003
|
}
|
|
1407
2004
|
}
|
|
1408
2005
|
if (!response.ok) {
|
|
1409
|
-
|
|
2006
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2007
|
+
return {
|
|
2008
|
+
ok: false,
|
|
2009
|
+
retryable: false,
|
|
2010
|
+
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (response.status === 401 || response.status === 403) {
|
|
2014
|
+
return {
|
|
2015
|
+
ok: false,
|
|
2016
|
+
retryable: false,
|
|
2017
|
+
error: new HasnaHttpError(method, rel, response.status, parsed, {
|
|
2018
|
+
source: credential.source,
|
|
2019
|
+
tier: credential.tier,
|
|
2020
|
+
guidance: authFailureGuidance(credential)
|
|
2021
|
+
})
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
const retry = resolveRetry(opts.retry);
|
|
2025
|
+
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
2026
|
+
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
1410
2027
|
}
|
|
1411
2028
|
return { ok: true, value: parsed };
|
|
1412
2029
|
}
|
|
@@ -1414,24 +2031,23 @@ function createHttpTransport(options) {
|
|
|
1414
2031
|
const upper = method.toUpperCase();
|
|
1415
2032
|
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
1416
2033
|
const url = `${base}${rel}`;
|
|
1417
|
-
const
|
|
1418
|
-
const
|
|
1419
|
-
const maxAttempts = methodRetryable ?
|
|
2034
|
+
const retry = resolveRetry(opts.retry);
|
|
2035
|
+
const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
|
|
2036
|
+
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2037
|
+
const credential = currentCredential(options.name, options.apiKey);
|
|
1420
2038
|
let last = null;
|
|
1421
2039
|
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
1422
|
-
const result = await once(upper, rel, url, body, opts);
|
|
2040
|
+
const result = await once(upper, rel, url, body, opts, credential);
|
|
1423
2041
|
if (result.ok)
|
|
1424
2042
|
return result.value;
|
|
1425
2043
|
last = result;
|
|
1426
|
-
const canRetry = methodRetryable && result.retryable && attempt < maxAttempts;
|
|
2044
|
+
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
1427
2045
|
if (!canRetry)
|
|
1428
2046
|
break;
|
|
1429
|
-
const backoff = Math.min(
|
|
2047
|
+
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
1430
2048
|
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
1431
2049
|
await sleep(backoff + jitter);
|
|
1432
2050
|
}
|
|
1433
|
-
if (last === null)
|
|
1434
|
-
throw new Error(`Request to ${rel} completed without a result`);
|
|
1435
2051
|
throw last.error;
|
|
1436
2052
|
}
|
|
1437
2053
|
return {
|
|
@@ -1439,51 +2055,1200 @@ function createHttpTransport(options) {
|
|
|
1439
2055
|
request,
|
|
1440
2056
|
get: (path, opts) => request("GET", path, undefined, opts),
|
|
1441
2057
|
post: (path, body, opts) => request("POST", path, body, opts),
|
|
1442
|
-
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
1443
2058
|
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
2059
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
1444
2060
|
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
1445
2061
|
};
|
|
1446
2062
|
}
|
|
1447
|
-
function
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
2063
|
+
function createClientTransport(name, env = process.env, overrides) {
|
|
2064
|
+
const credentialOptions = overrides?.credentials;
|
|
2065
|
+
const resolution = resolveClientTransport(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
|
|
2066
|
+
if (resolution.misconfigured) {
|
|
2067
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
|
|
2068
|
+
}
|
|
2069
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
2070
|
+
return { transport: "sqlite", client: null, resolution };
|
|
2071
|
+
}
|
|
2072
|
+
const credentialProvider = () => {
|
|
2073
|
+
const resolved = resolveCredential(name, env, credentialOptions);
|
|
2074
|
+
if (!resolved) {
|
|
2075
|
+
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.`);
|
|
2076
|
+
}
|
|
2077
|
+
return resolved;
|
|
2078
|
+
};
|
|
2079
|
+
return {
|
|
2080
|
+
transport: "http",
|
|
2081
|
+
client: createHasnaHttpTransport({
|
|
2082
|
+
name,
|
|
2083
|
+
baseUrl: resolution.baseUrl,
|
|
2084
|
+
apiKey: credentialProvider,
|
|
2085
|
+
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2086
|
+
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2087
|
+
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2088
|
+
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2089
|
+
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2090
|
+
}),
|
|
2091
|
+
resolution
|
|
2092
|
+
};
|
|
1452
2093
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
2094
|
+
|
|
2095
|
+
// ../contracts/dist/client/storage.js
|
|
2096
|
+
import { isIP as isIP2 } from "net";
|
|
2097
|
+
import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
|
|
2098
|
+
import { join as join4 } from "path";
|
|
2099
|
+
function envToken2(name) {
|
|
2100
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
2101
|
+
}
|
|
2102
|
+
function clientTransportEnvKeys2(name) {
|
|
2103
|
+
const envSegment = envToken2(name);
|
|
2104
|
+
return {
|
|
2105
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
2106
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
function credentialOverrideEnvKey2(name) {
|
|
2110
|
+
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
2111
|
+
}
|
|
2112
|
+
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
2113
|
+
|
|
2114
|
+
class CredentialResolutionError2 extends Error {
|
|
2115
|
+
appName;
|
|
2116
|
+
attempted;
|
|
2117
|
+
constructor(appName, message, attempted) {
|
|
2118
|
+
super(message);
|
|
2119
|
+
this.name = "CredentialResolutionError";
|
|
2120
|
+
this.appName = appName;
|
|
2121
|
+
this.attempted = attempted;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
var HASNA_STATE_DIR2 = ".hasna";
|
|
2125
|
+
var FLEET_CREDENTIAL_DIR2 = "cloud";
|
|
2126
|
+
var CONFIG_DIR2 = ".config";
|
|
2127
|
+
var CONFIG_NAMESPACE2 = "hasna";
|
|
2128
|
+
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
2129
|
+
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2130
|
+
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
2131
|
+
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
2132
|
+
function homeDir2(env) {
|
|
2133
|
+
const home = env.HOME?.trim();
|
|
2134
|
+
return home ? home : null;
|
|
2135
|
+
}
|
|
2136
|
+
function credentialDiskSources2(name, env) {
|
|
2137
|
+
return profileDiskSources2(name, env, null);
|
|
2138
|
+
}
|
|
2139
|
+
function profileDiskSources2(name, env, profile) {
|
|
2140
|
+
const home = homeDir2(env);
|
|
2141
|
+
if (!home || !SAFE_APP_SLUG2.test(name))
|
|
2142
|
+
return [];
|
|
2143
|
+
const stem = profile ? `${name}.${profile}` : name;
|
|
2144
|
+
const configStem = profile ? `${name}-${profile}` : name;
|
|
2145
|
+
return [
|
|
2146
|
+
join4(home, HASNA_STATE_DIR2, FLEET_CREDENTIAL_DIR2, `${stem}.env`),
|
|
2147
|
+
join4(home, CONFIG_DIR2, CONFIG_NAMESPACE2, `${configStem}-cloud.env`)
|
|
2148
|
+
];
|
|
2149
|
+
}
|
|
2150
|
+
function parseEnvFile2(text) {
|
|
2151
|
+
const values = new Map;
|
|
2152
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
2153
|
+
const line = rawLine.trim();
|
|
2154
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
2155
|
+
continue;
|
|
2156
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
2157
|
+
const equals = withoutExport.indexOf("=");
|
|
2158
|
+
if (equals <= 0)
|
|
2159
|
+
continue;
|
|
2160
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
2161
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
2162
|
+
continue;
|
|
2163
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
2164
|
+
const quote = value[0];
|
|
2165
|
+
if (quote === '"' || quote === "'") {
|
|
2166
|
+
if (value.length < 2 || !value.endsWith(quote))
|
|
2167
|
+
continue;
|
|
2168
|
+
value = value.slice(1, -1);
|
|
1461
2169
|
}
|
|
2170
|
+
if (value.length === 0)
|
|
2171
|
+
continue;
|
|
2172
|
+
values.set(key, value);
|
|
1462
2173
|
}
|
|
1463
|
-
return
|
|
2174
|
+
return values;
|
|
1464
2175
|
}
|
|
1465
|
-
function
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
2176
|
+
function readAppConfigFile2(path) {
|
|
2177
|
+
let text;
|
|
2178
|
+
try {
|
|
2179
|
+
const stats = statSync3(path);
|
|
2180
|
+
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
2181
|
+
return null;
|
|
2182
|
+
text = readFileSync3(path, "utf8");
|
|
2183
|
+
} catch {
|
|
2184
|
+
return null;
|
|
2185
|
+
}
|
|
2186
|
+
return parseEnvFile2(text);
|
|
2187
|
+
}
|
|
2188
|
+
function readCredentialFile2(path, apiKeyKeys) {
|
|
2189
|
+
const values = readAppConfigFile2(path);
|
|
2190
|
+
if (!values)
|
|
2191
|
+
return null;
|
|
2192
|
+
for (const key of apiKeyKeys) {
|
|
2193
|
+
const value = values.get(key)?.trim();
|
|
2194
|
+
if (value)
|
|
2195
|
+
return value;
|
|
2196
|
+
}
|
|
2197
|
+
return null;
|
|
2198
|
+
}
|
|
2199
|
+
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2200
|
+
function appConfigDiskValue2(name, env, keys) {
|
|
2201
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY2.test(key));
|
|
2202
|
+
if (wanted.length === 0)
|
|
2203
|
+
return null;
|
|
2204
|
+
for (const path of credentialDiskSources2(name, env)) {
|
|
2205
|
+
const values = readAppConfigFile2(path);
|
|
2206
|
+
if (!values)
|
|
2207
|
+
continue;
|
|
2208
|
+
for (const key of wanted) {
|
|
2209
|
+
const value = values.get(key)?.trim();
|
|
2210
|
+
if (value)
|
|
2211
|
+
return { key, value, path };
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
return null;
|
|
2215
|
+
}
|
|
2216
|
+
function assertUsableCredential2(appName, source, value) {
|
|
2217
|
+
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
2218
|
+
return;
|
|
2219
|
+
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]);
|
|
2220
|
+
}
|
|
2221
|
+
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
2222
|
+
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
2223
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2 = "caller-supplied CredentialProvider";
|
|
2224
|
+
function sealCredential2(fields) {
|
|
2225
|
+
const { apiKey } = fields;
|
|
2226
|
+
const visible = {
|
|
2227
|
+
tier: fields.tier,
|
|
2228
|
+
source: fields.source,
|
|
2229
|
+
deliberate: fields.deliberate,
|
|
2230
|
+
deprecated: fields.deprecated,
|
|
2231
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
2232
|
+
warning: fields.warning
|
|
2233
|
+
};
|
|
2234
|
+
const sealed = { ...visible };
|
|
2235
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
2236
|
+
value: apiKey,
|
|
2237
|
+
enumerable: false,
|
|
2238
|
+
writable: false,
|
|
2239
|
+
configurable: false
|
|
2240
|
+
});
|
|
2241
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
2242
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
2243
|
+
enumerable: false,
|
|
2244
|
+
writable: false,
|
|
2245
|
+
configurable: false
|
|
2246
|
+
});
|
|
2247
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
|
|
2248
|
+
value: true,
|
|
2249
|
+
enumerable: false,
|
|
2250
|
+
writable: false,
|
|
2251
|
+
configurable: false
|
|
2252
|
+
});
|
|
2253
|
+
return Object.freeze(sealed);
|
|
2254
|
+
}
|
|
2255
|
+
function isSealedCredential2(credential) {
|
|
2256
|
+
return credential[CREDENTIAL_SEAL2] === true;
|
|
2257
|
+
}
|
|
2258
|
+
function explicitCredential2(appName, apiKey) {
|
|
2259
|
+
const source = "explicit apiKey option";
|
|
2260
|
+
assertUsableCredential2(appName, source, apiKey);
|
|
2261
|
+
return sealCredential2({
|
|
2262
|
+
apiKey,
|
|
2263
|
+
tier: "argument",
|
|
2264
|
+
source,
|
|
2265
|
+
deliberate: true,
|
|
2266
|
+
deprecated: false,
|
|
2267
|
+
diskCandidates: [],
|
|
2268
|
+
warning: null
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
function validateAndSealResolvedCredential2(appName, credential) {
|
|
2272
|
+
const apiKey = credential.apiKey;
|
|
2273
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2, apiKey);
|
|
2274
|
+
if (!isSealedCredential2(credential)) {
|
|
2275
|
+
return sealCredential2({
|
|
2276
|
+
apiKey,
|
|
2277
|
+
tier: "argument",
|
|
2278
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2,
|
|
2279
|
+
deliberate: true,
|
|
2280
|
+
deprecated: false,
|
|
2281
|
+
diskCandidates: [],
|
|
2282
|
+
warning: null
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
return sealCredential2({
|
|
2286
|
+
apiKey,
|
|
2287
|
+
tier: credential.tier,
|
|
2288
|
+
source: credential.source,
|
|
2289
|
+
deliberate: credential.deliberate,
|
|
2290
|
+
deprecated: credential.deprecated,
|
|
2291
|
+
diskCandidates: credential.diskCandidates,
|
|
2292
|
+
warning: credential.warning
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
function firstEnvValue2(env, keys) {
|
|
2296
|
+
for (const key of keys) {
|
|
2297
|
+
const value = env[key]?.trim();
|
|
2298
|
+
if (value)
|
|
2299
|
+
return { key, value };
|
|
2300
|
+
}
|
|
2301
|
+
return null;
|
|
2302
|
+
}
|
|
2303
|
+
var DEPRECATION_REGISTRY2 = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
2304
|
+
function deprecationNotified2() {
|
|
2305
|
+
const host = globalThis;
|
|
2306
|
+
const existing = host[DEPRECATION_REGISTRY2];
|
|
2307
|
+
if (existing instanceof Set)
|
|
2308
|
+
return existing;
|
|
2309
|
+
const created = new Set;
|
|
2310
|
+
host[DEPRECATION_REGISTRY2] = created;
|
|
2311
|
+
return created;
|
|
2312
|
+
}
|
|
2313
|
+
function defaultDeprecationSink2(message) {
|
|
2314
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
2315
|
+
process.stderr.write(`${message}
|
|
2316
|
+
`);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
function resolveCredential2(name, env, options = {}) {
|
|
2320
|
+
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
2321
|
+
const diskPaths = credentialDiskSources2(name, env);
|
|
2322
|
+
const explicitKey = options.apiKey?.trim();
|
|
2323
|
+
if (explicitKey) {
|
|
2324
|
+
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
2325
|
+
return sealCredential2({
|
|
2326
|
+
apiKey: explicitKey,
|
|
2327
|
+
tier: "argument",
|
|
2328
|
+
source: "explicit apiKey argument",
|
|
2329
|
+
deliberate: true,
|
|
2330
|
+
deprecated: false,
|
|
2331
|
+
diskCandidates: diskPaths,
|
|
2332
|
+
warning: null
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
2336
|
+
const overrideRaw = env[overrideKeyName];
|
|
2337
|
+
if (overrideRaw !== undefined) {
|
|
2338
|
+
const override = overrideRaw.trim();
|
|
2339
|
+
if (!override) {
|
|
2340
|
+
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]);
|
|
2341
|
+
}
|
|
2342
|
+
assertUsableCredential2(name, overrideKeyName, override);
|
|
2343
|
+
return sealCredential2({
|
|
2344
|
+
apiKey: override,
|
|
2345
|
+
tier: "override",
|
|
2346
|
+
source: overrideKeyName,
|
|
2347
|
+
deliberate: true,
|
|
2348
|
+
deprecated: false,
|
|
2349
|
+
diskCandidates: diskPaths,
|
|
2350
|
+
warning: null
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY2]?.trim();
|
|
2354
|
+
if (profile) {
|
|
2355
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
2356
|
+
if (!SAFE_PROFILE2.test(profile)) {
|
|
2357
|
+
throw new CredentialResolutionError2(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
2358
|
+
}
|
|
2359
|
+
const paths = profileDiskSources2(name, env, profile);
|
|
2360
|
+
for (const path of paths) {
|
|
2361
|
+
const value = readCredentialFile2(path, apiKeyKeys);
|
|
2362
|
+
if (value) {
|
|
2363
|
+
assertUsableCredential2(name, path, value);
|
|
2364
|
+
return sealCredential2({
|
|
2365
|
+
apiKey: value,
|
|
2366
|
+
tier: "profile",
|
|
2367
|
+
source: path,
|
|
2368
|
+
deliberate: true,
|
|
2369
|
+
deprecated: false,
|
|
2370
|
+
diskCandidates: paths,
|
|
2371
|
+
warning: null
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
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);
|
|
2376
|
+
}
|
|
2377
|
+
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile2(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
2378
|
+
if (diskHits.length > 0) {
|
|
2379
|
+
const winner = diskHits[0];
|
|
2380
|
+
assertUsableCredential2(name, winner.path, winner.value);
|
|
2381
|
+
const divergentSources = [
|
|
2382
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
2383
|
+
...(() => {
|
|
2384
|
+
const legacyHit = firstEnvValue2(env, apiKeyKeys);
|
|
2385
|
+
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
2386
|
+
})()
|
|
2387
|
+
];
|
|
2388
|
+
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;
|
|
2389
|
+
return sealCredential2({
|
|
2390
|
+
apiKey: winner.value,
|
|
2391
|
+
tier: "disk",
|
|
2392
|
+
source: winner.path,
|
|
2393
|
+
deliberate: false,
|
|
2394
|
+
deprecated: false,
|
|
2395
|
+
diskCandidates: diskPaths,
|
|
2396
|
+
warning
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
const legacy = firstEnvValue2(env, apiKeyKeys);
|
|
2400
|
+
if (legacy) {
|
|
2401
|
+
assertUsableCredential2(name, legacy.key, legacy.value);
|
|
2402
|
+
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.`;
|
|
2403
|
+
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}`;
|
|
2404
|
+
const sink = options.onDeprecation ?? defaultDeprecationSink2;
|
|
2405
|
+
const notified = deprecationNotified2();
|
|
2406
|
+
if (!notified.has(name)) {
|
|
2407
|
+
notified.add(name);
|
|
2408
|
+
sink(message);
|
|
2409
|
+
}
|
|
2410
|
+
return sealCredential2({
|
|
2411
|
+
apiKey: legacy.value,
|
|
2412
|
+
tier: "legacy-env",
|
|
2413
|
+
source: legacy.key,
|
|
2414
|
+
deliberate: false,
|
|
2415
|
+
deprecated: true,
|
|
2416
|
+
diskCandidates: diskPaths,
|
|
2417
|
+
warning: message
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
return null;
|
|
2421
|
+
}
|
|
2422
|
+
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
2423
|
+
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2424
|
+
function isValidDnsDomain2(value) {
|
|
2425
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN2.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
2426
|
+
return false;
|
|
2427
|
+
}
|
|
2428
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
2429
|
+
}
|
|
2430
|
+
function firstEnv2(env, keys, options = {}) {
|
|
2431
|
+
for (const key of keys) {
|
|
2432
|
+
const raw = env[key];
|
|
2433
|
+
const value = raw?.trim();
|
|
2434
|
+
if (value)
|
|
2435
|
+
return { key, value: options.preserveRaw ? raw : value };
|
|
2436
|
+
}
|
|
2437
|
+
return null;
|
|
2438
|
+
}
|
|
2439
|
+
function firstEnvDefinedKey2(env, keys) {
|
|
2440
|
+
for (const key of keys) {
|
|
2441
|
+
if (env[key] !== undefined)
|
|
2442
|
+
return key;
|
|
2443
|
+
}
|
|
2444
|
+
return null;
|
|
2445
|
+
}
|
|
2446
|
+
function rawAuthority2(value) {
|
|
2447
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
2448
|
+
if (!match)
|
|
2449
|
+
throw new Error("API URL must be absolute.");
|
|
2450
|
+
const afterScheme = value.slice(match[0].length);
|
|
2451
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
2452
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
2453
|
+
if (!authority)
|
|
2454
|
+
throw new Error("API URL must include a hostname.");
|
|
2455
|
+
return authority;
|
|
2456
|
+
}
|
|
2457
|
+
function assertCanonicalPort2(port) {
|
|
2458
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
2459
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2460
|
+
}
|
|
2461
|
+
const numericPort = Number(port);
|
|
2462
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
2463
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
function canonicalAuthorityHostname2(authority) {
|
|
2467
|
+
let rawHostname;
|
|
2468
|
+
if (authority.startsWith("[")) {
|
|
2469
|
+
const closingBracket = authority.indexOf("]");
|
|
2470
|
+
if (closingBracket === -1) {
|
|
2471
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
2472
|
+
}
|
|
2473
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
2474
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
2475
|
+
if (portSuffix) {
|
|
2476
|
+
if (!portSuffix.startsWith(":")) {
|
|
2477
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
2478
|
+
}
|
|
2479
|
+
assertCanonicalPort2(portSuffix.slice(1));
|
|
2480
|
+
}
|
|
2481
|
+
if (isIP2(rawHostname.slice(1, -1)) !== 6) {
|
|
2482
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
2483
|
+
}
|
|
2484
|
+
} else {
|
|
2485
|
+
const firstColon = authority.indexOf(":");
|
|
2486
|
+
const lastColon = authority.lastIndexOf(":");
|
|
2487
|
+
if (firstColon !== lastColon) {
|
|
2488
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
2489
|
+
}
|
|
2490
|
+
if (lastColon !== -1) {
|
|
2491
|
+
const port = authority.slice(lastColon + 1);
|
|
2492
|
+
assertCanonicalPort2(port);
|
|
2493
|
+
rawHostname = authority.slice(0, lastColon);
|
|
2494
|
+
} else {
|
|
2495
|
+
rawHostname = authority;
|
|
2496
|
+
}
|
|
2497
|
+
const ipVersion = isIP2(rawHostname);
|
|
2498
|
+
const numericAddressParts = rawHostname.split(".");
|
|
2499
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
2500
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain2(rawHostname.toLowerCase())) {
|
|
2501
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
return rawHostname.toLowerCase();
|
|
2505
|
+
}
|
|
2506
|
+
function isDeliberateLoopbackHttpAuthority2(authority) {
|
|
2507
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
2508
|
+
}
|
|
2509
|
+
function toV1BaseUrl2(apiUrl) {
|
|
2510
|
+
if (ASCII_CONTROL_PATTERN2.test(apiUrl)) {
|
|
2511
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
2512
|
+
}
|
|
2513
|
+
const input = apiUrl.trim();
|
|
2514
|
+
const authority = rawAuthority2(input);
|
|
2515
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
2516
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
2517
|
+
}
|
|
2518
|
+
const canonicalHostname = canonicalAuthorityHostname2(authority);
|
|
2519
|
+
const url = new URL(input);
|
|
2520
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2521
|
+
throw new Error("API URL must use http or https.");
|
|
2522
|
+
}
|
|
2523
|
+
if (url.username || url.password) {
|
|
2524
|
+
throw new Error("API URL must not include credentials.");
|
|
2525
|
+
}
|
|
2526
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
2527
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
2528
|
+
}
|
|
2529
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
2530
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
2531
|
+
}
|
|
2532
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
2533
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
2534
|
+
}
|
|
2535
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority2(authority)) {
|
|
2536
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
2537
|
+
}
|
|
2538
|
+
if (url.search || url.hash) {
|
|
2539
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
2540
|
+
}
|
|
2541
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
2542
|
+
if (path.endsWith("/v1"))
|
|
2543
|
+
path = path.slice(0, -"/v1".length);
|
|
2544
|
+
url.pathname = `${path}/v1`;
|
|
2545
|
+
return url.toString().replace(/\/+$/, "");
|
|
2546
|
+
}
|
|
2547
|
+
function resolveClientTransport2(name, env = process.env, options = {}) {
|
|
2548
|
+
const keys = clientTransportEnvKeys2(name);
|
|
2549
|
+
const envUrlHit = firstEnv2(env, keys.apiUrlKeys, { preserveRaw: true });
|
|
2550
|
+
const explicitLocalKey = envUrlHit ? null : firstEnvDefinedKey2(env, keys.apiUrlKeys);
|
|
2551
|
+
const diskUrlHit = envUrlHit || explicitLocalKey ? null : appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
2552
|
+
const urlHit = envUrlHit ?? (diskUrlHit ? { key: diskUrlHit.path, value: diskUrlHit.value } : null);
|
|
2553
|
+
const keyHit = firstEnv2(env, keys.apiKeyKeys);
|
|
2554
|
+
const warnings = [];
|
|
2555
|
+
if (!urlHit) {
|
|
2556
|
+
if (explicitLocalKey) {
|
|
2557
|
+
const overriddenPointer = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
2558
|
+
if (overriddenPointer) {
|
|
2559
|
+
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.`);
|
|
2560
|
+
}
|
|
2561
|
+
return {
|
|
2562
|
+
transport: "sqlite",
|
|
2563
|
+
transportSource: explicitLocalKey,
|
|
2564
|
+
baseUrl: null,
|
|
2565
|
+
apiUrlSource: null,
|
|
2566
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2567
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
2568
|
+
apiKeyTier: null,
|
|
2569
|
+
misconfigured: false,
|
|
2570
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
return {
|
|
2574
|
+
transport: "sqlite",
|
|
2575
|
+
transportSource: "default",
|
|
2576
|
+
baseUrl: null,
|
|
2577
|
+
apiUrlSource: null,
|
|
2578
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2579
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
2580
|
+
apiKeyTier: null,
|
|
2581
|
+
misconfigured: false,
|
|
2582
|
+
warning: null
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
if (diskUrlHit) {
|
|
2586
|
+
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.`);
|
|
2587
|
+
}
|
|
2588
|
+
const credential = resolveCredential2(name, env, options.credentials);
|
|
2589
|
+
if (!credential) {
|
|
2590
|
+
const diskHint = credentialDiskSourcesForMessage2(name, env);
|
|
2591
|
+
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.`);
|
|
2592
|
+
return {
|
|
2593
|
+
transport: "sqlite",
|
|
2594
|
+
transportSource: urlHit.key,
|
|
2595
|
+
baseUrl: null,
|
|
2596
|
+
apiUrlSource: urlHit.key,
|
|
2597
|
+
apiKeyPresent: false,
|
|
2598
|
+
apiKeySource: null,
|
|
2599
|
+
apiKeyTier: null,
|
|
2600
|
+
misconfigured: true,
|
|
2601
|
+
warning: warnings.join(" ")
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
if (credential.warning)
|
|
2605
|
+
warnings.push(credential.warning);
|
|
2606
|
+
const apiUrlSource = urlHit.key;
|
|
2607
|
+
let baseUrl;
|
|
2608
|
+
try {
|
|
2609
|
+
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
2610
|
+
} catch (error) {
|
|
2611
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2612
|
+
warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
|
|
2613
|
+
return {
|
|
2614
|
+
transport: "sqlite",
|
|
2615
|
+
transportSource: urlHit.key,
|
|
2616
|
+
baseUrl: null,
|
|
2617
|
+
apiUrlSource: urlHit.key,
|
|
2618
|
+
apiKeyPresent: true,
|
|
2619
|
+
apiKeySource: credential.source,
|
|
2620
|
+
apiKeyTier: credential.tier,
|
|
2621
|
+
misconfigured: true,
|
|
2622
|
+
warning: warnings.join(" ")
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
return {
|
|
2626
|
+
transport: "http",
|
|
2627
|
+
transportSource: urlHit.key,
|
|
2628
|
+
baseUrl,
|
|
2629
|
+
apiUrlSource,
|
|
2630
|
+
apiKeyPresent: true,
|
|
2631
|
+
apiKeySource: credential.source,
|
|
2632
|
+
apiKeyTier: credential.tier,
|
|
2633
|
+
misconfigured: false,
|
|
2634
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
function credentialDiskSourcesForMessage2(name, env) {
|
|
2638
|
+
const paths = credentialDiskSources2(name, env);
|
|
2639
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
class HasnaHttpError2 extends Error {
|
|
2643
|
+
status;
|
|
2644
|
+
method;
|
|
2645
|
+
path;
|
|
2646
|
+
body;
|
|
2647
|
+
credentialSource;
|
|
2648
|
+
credentialTier;
|
|
2649
|
+
constructor(method, path, status, body, credential) {
|
|
2650
|
+
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
2651
|
+
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
2652
|
+
this.name = "HasnaHttpError";
|
|
2653
|
+
this.status = status;
|
|
2654
|
+
this.method = method;
|
|
2655
|
+
this.path = path;
|
|
2656
|
+
this.body = body;
|
|
2657
|
+
this.credentialSource = credential?.source ?? null;
|
|
2658
|
+
this.credentialTier = credential?.tier ?? null;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
function currentCredential2(name, apiKey) {
|
|
2662
|
+
if (typeof apiKey === "function") {
|
|
2663
|
+
return validateAndSealResolvedCredential2(name, apiKey());
|
|
2664
|
+
}
|
|
2665
|
+
return explicitCredential2(name, apiKey);
|
|
2666
|
+
}
|
|
2667
|
+
function authFailureGuidance2(credential) {
|
|
2668
|
+
const origin = `The API key for this request came from ${credential.source}`;
|
|
2669
|
+
if (credential.deliberate) {
|
|
2670
|
+
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.`;
|
|
2671
|
+
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}`;
|
|
2672
|
+
}
|
|
2673
|
+
if (credential.deprecated) {
|
|
2674
|
+
const target = credential.diskCandidates[0];
|
|
2675
|
+
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.`;
|
|
2676
|
+
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}`;
|
|
2677
|
+
}
|
|
2678
|
+
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.`;
|
|
2679
|
+
}
|
|
2680
|
+
var DEFAULT_RETRY_STATUSES2 = [408, 425, 429, 500, 502, 503, 504];
|
|
2681
|
+
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2682
|
+
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2683
|
+
"host",
|
|
2684
|
+
":authority",
|
|
2685
|
+
"forwarded",
|
|
2686
|
+
"x-forwarded-host",
|
|
2687
|
+
"x-original-host"
|
|
2688
|
+
]);
|
|
2689
|
+
function assertNoAuthorityOverrideHeaders2(headers, source) {
|
|
2690
|
+
if (!headers)
|
|
2691
|
+
return;
|
|
2692
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
2693
|
+
if (forbidden) {
|
|
2694
|
+
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
function appendQuery2(path, query) {
|
|
2698
|
+
if (!query)
|
|
2699
|
+
return path;
|
|
2700
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
2701
|
+
if (!(query instanceof URLSearchParams)) {
|
|
2702
|
+
for (const [key, value] of Object.entries(query)) {
|
|
2703
|
+
if (value === null || value === undefined)
|
|
2704
|
+
continue;
|
|
2705
|
+
if (Array.isArray(value)) {
|
|
2706
|
+
for (const v of value)
|
|
2707
|
+
params.append(key, String(v));
|
|
2708
|
+
} else {
|
|
2709
|
+
params.append(key, String(value));
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
const qs = params.toString();
|
|
2714
|
+
if (!qs)
|
|
2715
|
+
return path;
|
|
2716
|
+
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
2717
|
+
}
|
|
2718
|
+
var defaultSleep2 = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2719
|
+
function createHasnaHttpTransport2(options) {
|
|
2720
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2721
|
+
const base = toV1BaseUrl2(options.baseUrl);
|
|
2722
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2723
|
+
const sleep = options.sleepImpl ?? defaultSleep2;
|
|
2724
|
+
const defaultRetry = options.retry;
|
|
2725
|
+
function resolveRetry(callRetry) {
|
|
2726
|
+
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
2727
|
+
if (chosen === false)
|
|
2728
|
+
return null;
|
|
2729
|
+
const r = chosen ?? {};
|
|
2730
|
+
return {
|
|
2731
|
+
retries: r.retries ?? 2,
|
|
2732
|
+
baseDelayMs: r.baseDelayMs ?? 200,
|
|
2733
|
+
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
2734
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES2]
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
async function once(method, rel, url, body, opts, credential) {
|
|
2738
|
+
assertNoAuthorityOverrideHeaders2(options.headers, "transport");
|
|
2739
|
+
assertNoAuthorityOverrideHeaders2(opts.headers, "request");
|
|
2740
|
+
const headers = {
|
|
2741
|
+
"x-api-key": credential.apiKey,
|
|
2742
|
+
Authorization: `Bearer ${credential.apiKey}`,
|
|
2743
|
+
Accept: "application/json",
|
|
2744
|
+
...options.headers ?? {},
|
|
2745
|
+
...opts.headers ?? {}
|
|
2746
|
+
};
|
|
2747
|
+
if (opts.idempotencyKey)
|
|
2748
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
2749
|
+
const init = {
|
|
2750
|
+
method,
|
|
2751
|
+
headers,
|
|
2752
|
+
redirect: "manual"
|
|
2753
|
+
};
|
|
2754
|
+
if (body !== undefined) {
|
|
2755
|
+
headers["Content-Type"] = "application/json";
|
|
2756
|
+
init.body = JSON.stringify(body);
|
|
2757
|
+
}
|
|
2758
|
+
const controller = new AbortController;
|
|
2759
|
+
const onAbort = () => controller.abort();
|
|
2760
|
+
if (opts.signal) {
|
|
2761
|
+
if (opts.signal.aborted)
|
|
2762
|
+
controller.abort();
|
|
2763
|
+
else
|
|
2764
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
2765
|
+
}
|
|
2766
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
2767
|
+
init.signal = controller.signal;
|
|
2768
|
+
let response;
|
|
2769
|
+
try {
|
|
2770
|
+
response = await fetchImpl(url, init);
|
|
2771
|
+
} catch (error) {
|
|
2772
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2773
|
+
if (opts.signal?.aborted)
|
|
2774
|
+
return { ok: false, retryable: false, error: err };
|
|
2775
|
+
return { ok: false, retryable: true, error: err };
|
|
2776
|
+
} finally {
|
|
2777
|
+
clearTimeout(timer);
|
|
2778
|
+
if (opts.signal)
|
|
2779
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
2780
|
+
}
|
|
2781
|
+
const text = await response.text();
|
|
2782
|
+
let parsed = undefined;
|
|
2783
|
+
if (text.length > 0) {
|
|
2784
|
+
try {
|
|
2785
|
+
parsed = JSON.parse(text);
|
|
2786
|
+
} catch {
|
|
2787
|
+
parsed = text;
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
if (!response.ok) {
|
|
2791
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2792
|
+
return {
|
|
2793
|
+
ok: false,
|
|
2794
|
+
retryable: false,
|
|
2795
|
+
error: new HasnaHttpError2(method, rel, response.status, parsed)
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
if (response.status === 401 || response.status === 403) {
|
|
2799
|
+
return {
|
|
2800
|
+
ok: false,
|
|
2801
|
+
retryable: false,
|
|
2802
|
+
error: new HasnaHttpError2(method, rel, response.status, parsed, {
|
|
2803
|
+
source: credential.source,
|
|
2804
|
+
tier: credential.tier,
|
|
2805
|
+
guidance: authFailureGuidance2(credential)
|
|
2806
|
+
})
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
const retry = resolveRetry(opts.retry);
|
|
2810
|
+
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
2811
|
+
return { ok: false, retryable, error: new HasnaHttpError2(method, rel, response.status, parsed) };
|
|
2812
|
+
}
|
|
2813
|
+
return { ok: true, value: parsed };
|
|
2814
|
+
}
|
|
2815
|
+
async function request(method, path, body, opts = {}) {
|
|
2816
|
+
const upper = method.toUpperCase();
|
|
2817
|
+
const rel = appendQuery2(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2818
|
+
const url = `${base}${rel}`;
|
|
2819
|
+
const retry = resolveRetry(opts.retry);
|
|
2820
|
+
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
2821
|
+
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2822
|
+
const credential = currentCredential2(options.name, options.apiKey);
|
|
2823
|
+
let last = null;
|
|
2824
|
+
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2825
|
+
const result = await once(upper, rel, url, body, opts, credential);
|
|
2826
|
+
if (result.ok)
|
|
2827
|
+
return result.value;
|
|
2828
|
+
last = result;
|
|
2829
|
+
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
2830
|
+
if (!canRetry)
|
|
2831
|
+
break;
|
|
2832
|
+
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
2833
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
2834
|
+
await sleep(backoff + jitter);
|
|
2835
|
+
}
|
|
2836
|
+
throw last.error;
|
|
2837
|
+
}
|
|
2838
|
+
return {
|
|
2839
|
+
baseUrl: base,
|
|
2840
|
+
request,
|
|
2841
|
+
get: (path, opts) => request("GET", path, undefined, opts),
|
|
2842
|
+
post: (path, body, opts) => request("POST", path, body, opts),
|
|
2843
|
+
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
2844
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
2845
|
+
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
function createClientTransport2(name, env = process.env, overrides) {
|
|
2849
|
+
const credentialOptions = overrides?.credentials;
|
|
2850
|
+
const resolution = resolveClientTransport2(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
|
|
2851
|
+
if (resolution.misconfigured) {
|
|
2852
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
|
|
2853
|
+
}
|
|
2854
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
2855
|
+
return { transport: "sqlite", client: null, resolution };
|
|
2856
|
+
}
|
|
2857
|
+
const credentialProvider = () => {
|
|
2858
|
+
const resolved = resolveCredential2(name, env, credentialOptions);
|
|
2859
|
+
if (!resolved) {
|
|
2860
|
+
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.`);
|
|
2861
|
+
}
|
|
2862
|
+
return resolved;
|
|
2863
|
+
};
|
|
2864
|
+
return {
|
|
2865
|
+
transport: "http",
|
|
2866
|
+
client: createHasnaHttpTransport2({
|
|
2867
|
+
name,
|
|
2868
|
+
baseUrl: resolution.baseUrl,
|
|
2869
|
+
apiKey: credentialProvider,
|
|
2870
|
+
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2871
|
+
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2872
|
+
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2873
|
+
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2874
|
+
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2875
|
+
}),
|
|
2876
|
+
resolution
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
function resourcePath(resource) {
|
|
2880
|
+
const trimmed = resource.replace(/^\/+|\/+$/g, "");
|
|
2881
|
+
if (!trimmed)
|
|
2882
|
+
throw new Error("resource must be a non-empty path segment");
|
|
2883
|
+
return `/${trimmed}`;
|
|
2884
|
+
}
|
|
2885
|
+
function entityPath(resource, id) {
|
|
2886
|
+
if (id === undefined || id === null || `${id}`.length === 0) {
|
|
2887
|
+
throw new Error("id must be a non-empty string");
|
|
2888
|
+
}
|
|
2889
|
+
return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`;
|
|
2890
|
+
}
|
|
2891
|
+
function newIdempotencyKey() {
|
|
2892
|
+
const g = globalThis;
|
|
2893
|
+
if (g.crypto?.randomUUID)
|
|
2894
|
+
return g.crypto.randomUUID();
|
|
2895
|
+
return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
2896
|
+
}
|
|
2897
|
+
function extractItems(raw) {
|
|
2898
|
+
if (Array.isArray(raw))
|
|
2899
|
+
return raw;
|
|
2900
|
+
if (raw && typeof raw === "object") {
|
|
2901
|
+
const obj = raw;
|
|
2902
|
+
for (const key of ["items", "data", "results", "rows", "records"]) {
|
|
2903
|
+
if (Array.isArray(obj[key]))
|
|
2904
|
+
return obj[key];
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
return [];
|
|
2908
|
+
}
|
|
2909
|
+
function extractTotal(raw) {
|
|
2910
|
+
if (raw && typeof raw === "object") {
|
|
2911
|
+
const obj = raw;
|
|
2912
|
+
for (const key of ["total", "count", "totalCount", "total_count"]) {
|
|
2913
|
+
if (typeof obj[key] === "number")
|
|
2914
|
+
return obj[key];
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
return null;
|
|
2918
|
+
}
|
|
2919
|
+
function extractCursor(raw) {
|
|
2920
|
+
if (raw && typeof raw === "object") {
|
|
2921
|
+
const obj = raw;
|
|
2922
|
+
for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) {
|
|
2923
|
+
if (typeof obj[key] === "string")
|
|
2924
|
+
return obj[key];
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
return null;
|
|
2928
|
+
}
|
|
2929
|
+
function isNotFoundHttpError(error) {
|
|
2930
|
+
return typeof error === "object" && error !== null && error.name === "HasnaHttpError" && error.status === 404;
|
|
2931
|
+
}
|
|
2932
|
+
function createHasnaStorageClient(name, transport) {
|
|
2933
|
+
return {
|
|
2934
|
+
name,
|
|
2935
|
+
baseUrl: transport.baseUrl,
|
|
2936
|
+
transport,
|
|
2937
|
+
async list(resource, options = {}) {
|
|
2938
|
+
const raw = await transport.get(resourcePath(resource), options);
|
|
2939
|
+
return {
|
|
2940
|
+
items: extractItems(raw),
|
|
2941
|
+
total: extractTotal(raw),
|
|
2942
|
+
cursor: extractCursor(raw),
|
|
2943
|
+
raw
|
|
2944
|
+
};
|
|
2945
|
+
},
|
|
2946
|
+
async get(resource, id, options = {}) {
|
|
2947
|
+
try {
|
|
2948
|
+
return await transport.get(entityPath(resource, id), options);
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
if (isNotFoundHttpError(error))
|
|
2951
|
+
return null;
|
|
2952
|
+
throw error;
|
|
2953
|
+
}
|
|
2954
|
+
},
|
|
2955
|
+
async create(resource, body, options = {}) {
|
|
2956
|
+
const { idempotencyKey, ...rest } = options;
|
|
2957
|
+
return transport.post(resourcePath(resource), body, {
|
|
2958
|
+
...rest,
|
|
2959
|
+
idempotencyKey: idempotencyKey ?? newIdempotencyKey()
|
|
2960
|
+
});
|
|
2961
|
+
},
|
|
2962
|
+
async update(resource, id, patch, options = {}) {
|
|
2963
|
+
const { method = "PATCH", idempotencyKey, ...rest } = options;
|
|
2964
|
+
const call = method === "PUT" ? transport.put : transport.patch;
|
|
2965
|
+
return call(entityPath(resource, id), patch, { ...rest, ...idempotencyKey ? { idempotencyKey } : {} });
|
|
2966
|
+
},
|
|
2967
|
+
async delete(resource, id, options = {}) {
|
|
2968
|
+
try {
|
|
2969
|
+
await transport.del(entityPath(resource, id), undefined, options);
|
|
2970
|
+
} catch (error) {
|
|
2971
|
+
if (isNotFoundHttpError(error))
|
|
2972
|
+
return;
|
|
2973
|
+
throw error;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
function resolveStorageClient(name, env = process.env, overrides) {
|
|
2979
|
+
const wired = createClientTransport2(name, env, overrides);
|
|
2980
|
+
if (wired.transport === "http") {
|
|
2981
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
2982
|
+
}
|
|
2983
|
+
return { transport: "sqlite", client: null };
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
// src/http/client.ts
|
|
2987
|
+
function envToken3(name) {
|
|
2988
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
2989
|
+
}
|
|
2990
|
+
function envKeys(name) {
|
|
2991
|
+
const token = envToken3(name);
|
|
2992
|
+
return {
|
|
2993
|
+
storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
|
|
2994
|
+
apiUrlKeys: [`HASNA_${token}_API_URL`],
|
|
2995
|
+
apiKeyKeys: [`HASNA_${token}_API_KEY`]
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
function normalizeClientStore(value) {
|
|
2999
|
+
const normalized = value.trim().toLowerCase();
|
|
3000
|
+
if (normalized === "sqlite")
|
|
3001
|
+
return "sqlite";
|
|
3002
|
+
if (normalized === "http" || normalized === "https")
|
|
3003
|
+
return "http";
|
|
3004
|
+
throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
|
|
3005
|
+
}
|
|
3006
|
+
function firstEnv3(env, keys) {
|
|
3007
|
+
for (const key of keys) {
|
|
3008
|
+
const value = env[key]?.trim();
|
|
3009
|
+
if (value)
|
|
3010
|
+
return { key, value };
|
|
3011
|
+
}
|
|
3012
|
+
return null;
|
|
3013
|
+
}
|
|
3014
|
+
function toV1BaseUrl3(apiUrl) {
|
|
3015
|
+
const url = new URL(apiUrl);
|
|
3016
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
3017
|
+
throw new Error("API URL must use http or https.");
|
|
3018
|
+
}
|
|
3019
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
3020
|
+
if (path.endsWith("/v1"))
|
|
3021
|
+
path = path.slice(0, -"/v1".length);
|
|
3022
|
+
url.pathname = `${path}/v1`;
|
|
3023
|
+
url.search = "";
|
|
3024
|
+
url.hash = "";
|
|
3025
|
+
return url.toString().replace(/\/+$/, "");
|
|
3026
|
+
}
|
|
3027
|
+
function resolveTransport(name, env = process.env) {
|
|
3028
|
+
const keys = envKeys(name);
|
|
3029
|
+
const storeHit = firstEnv3(env, keys.storeKeys);
|
|
3030
|
+
const urlHit = firstEnv3(env, keys.apiUrlKeys);
|
|
3031
|
+
const keyHit = firstEnv3(env, keys.apiKeyKeys);
|
|
3032
|
+
let requested = "sqlite";
|
|
3033
|
+
let modeSource = "default";
|
|
3034
|
+
if (storeHit) {
|
|
3035
|
+
requested = normalizeClientStore(storeHit.value);
|
|
3036
|
+
modeSource = storeHit.key;
|
|
3037
|
+
} else if (urlHit && keyHit) {
|
|
3038
|
+
requested = "http";
|
|
3039
|
+
modeSource = "auto:api-url+api-key";
|
|
3040
|
+
} else if (urlHit || keyHit) {
|
|
3041
|
+
const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
|
|
3042
|
+
const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
|
|
3043
|
+
return {
|
|
3044
|
+
transport: "sqlite",
|
|
3045
|
+
requested,
|
|
3046
|
+
modeSource,
|
|
3047
|
+
baseUrl: null,
|
|
3048
|
+
apiKeyPresent: Boolean(keyHit),
|
|
3049
|
+
misconfigured: true,
|
|
3050
|
+
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.`
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
if (requested === "sqlite") {
|
|
3054
|
+
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
3055
|
+
}
|
|
3056
|
+
if (!urlHit) {
|
|
3057
|
+
return {
|
|
3058
|
+
transport: "sqlite",
|
|
3059
|
+
requested,
|
|
3060
|
+
modeSource,
|
|
3061
|
+
baseUrl: null,
|
|
3062
|
+
apiKeyPresent: Boolean(keyHit),
|
|
3063
|
+
misconfigured: true,
|
|
3064
|
+
warning: `${modeSource}=http but no API URL is set (${keys.apiUrlKeys[0]}). Refusing to route to the API.`
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
if (!keyHit) {
|
|
3068
|
+
return {
|
|
3069
|
+
transport: "sqlite",
|
|
3070
|
+
requested,
|
|
3071
|
+
modeSource,
|
|
3072
|
+
baseUrl: null,
|
|
3073
|
+
apiKeyPresent: false,
|
|
3074
|
+
misconfigured: true,
|
|
3075
|
+
warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
const rawUrl = urlHit.value;
|
|
3079
|
+
let baseUrl;
|
|
3080
|
+
try {
|
|
3081
|
+
baseUrl = toV1BaseUrl3(rawUrl);
|
|
3082
|
+
} catch (error) {
|
|
3083
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3084
|
+
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
|
|
3085
|
+
}
|
|
3086
|
+
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
class HasnaHttpError3 extends Error {
|
|
3090
|
+
status;
|
|
3091
|
+
method;
|
|
3092
|
+
path;
|
|
3093
|
+
body;
|
|
3094
|
+
constructor(method, path, status, body) {
|
|
3095
|
+
super(`Hasna request failed: ${method} ${path} -> ${status}`);
|
|
3096
|
+
this.name = "HasnaHttpError";
|
|
3097
|
+
this.status = status;
|
|
3098
|
+
this.method = method;
|
|
3099
|
+
this.path = path;
|
|
3100
|
+
this.body = body;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
function appendQuery3(path, query) {
|
|
3104
|
+
if (!query)
|
|
3105
|
+
return path;
|
|
3106
|
+
const params = new URLSearchParams;
|
|
3107
|
+
for (const [key, value] of Object.entries(query)) {
|
|
3108
|
+
if (value === null || value === undefined)
|
|
3109
|
+
continue;
|
|
3110
|
+
if (Array.isArray(value))
|
|
3111
|
+
for (const v of value)
|
|
3112
|
+
params.append(key, String(v));
|
|
3113
|
+
else
|
|
3114
|
+
params.append(key, String(value));
|
|
3115
|
+
}
|
|
3116
|
+
const qs = params.toString();
|
|
3117
|
+
return qs ? `${path}${path.includes("?") ? "&" : "?"}${qs}` : path;
|
|
3118
|
+
}
|
|
3119
|
+
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
3120
|
+
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
3121
|
+
var defaultSleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3122
|
+
function createHttpTransport(options) {
|
|
3123
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
3124
|
+
const base = options.baseUrl.replace(/\/+$/, "");
|
|
3125
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
3126
|
+
const sleep = options.sleepImpl ?? defaultSleep3;
|
|
3127
|
+
async function once(method, rel, url, body, opts) {
|
|
3128
|
+
const headers = {
|
|
3129
|
+
"x-api-key": options.apiKey,
|
|
3130
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
3131
|
+
Accept: "application/json",
|
|
3132
|
+
...opts.headers ?? {}
|
|
3133
|
+
};
|
|
3134
|
+
if (opts.idempotencyKey)
|
|
3135
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
3136
|
+
const init = { method, headers };
|
|
3137
|
+
if (body !== undefined) {
|
|
3138
|
+
headers["Content-Type"] = "application/json";
|
|
3139
|
+
init.body = JSON.stringify(body);
|
|
3140
|
+
}
|
|
3141
|
+
const controller = new AbortController;
|
|
3142
|
+
const onAbort = () => controller.abort();
|
|
3143
|
+
if (opts.signal) {
|
|
3144
|
+
if (opts.signal.aborted)
|
|
3145
|
+
controller.abort();
|
|
3146
|
+
else
|
|
3147
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
3148
|
+
}
|
|
3149
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
3150
|
+
init.signal = controller.signal;
|
|
3151
|
+
let response;
|
|
3152
|
+
try {
|
|
3153
|
+
response = await fetchImpl(url, init);
|
|
3154
|
+
} catch (error) {
|
|
3155
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3156
|
+
if (opts.signal?.aborted)
|
|
3157
|
+
return { ok: false, retryable: false, error: err };
|
|
3158
|
+
return { ok: false, retryable: true, error: err };
|
|
3159
|
+
} finally {
|
|
3160
|
+
clearTimeout(timer);
|
|
3161
|
+
if (opts.signal)
|
|
3162
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
3163
|
+
}
|
|
3164
|
+
const text = await response.text();
|
|
3165
|
+
let parsed = undefined;
|
|
3166
|
+
if (text.length > 0) {
|
|
3167
|
+
try {
|
|
3168
|
+
parsed = JSON.parse(text);
|
|
3169
|
+
} catch {
|
|
3170
|
+
parsed = text;
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
if (!response.ok) {
|
|
3174
|
+
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new HasnaHttpError3(method, rel, response.status, parsed) };
|
|
3175
|
+
}
|
|
3176
|
+
return { ok: true, value: parsed };
|
|
3177
|
+
}
|
|
3178
|
+
async function request(method, path, body, opts = {}) {
|
|
3179
|
+
const upper = method.toUpperCase();
|
|
3180
|
+
const rel = appendQuery3(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
3181
|
+
const url = `${base}${rel}`;
|
|
3182
|
+
const methodRetryable = IDEMPOTENT.has(upper) || Boolean(opts.idempotencyKey);
|
|
3183
|
+
const maxRetries = opts.retries ?? 2;
|
|
3184
|
+
const maxAttempts = methodRetryable ? maxRetries + 1 : 1;
|
|
3185
|
+
let last = null;
|
|
3186
|
+
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
3187
|
+
const result = await once(upper, rel, url, body, opts);
|
|
3188
|
+
if (result.ok)
|
|
3189
|
+
return result.value;
|
|
3190
|
+
last = result;
|
|
3191
|
+
const canRetry = methodRetryable && result.retryable && attempt < maxAttempts;
|
|
3192
|
+
if (!canRetry)
|
|
3193
|
+
break;
|
|
3194
|
+
const backoff = Math.min(2000, 200 * 2 ** (attempt - 1));
|
|
3195
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
3196
|
+
await sleep(backoff + jitter);
|
|
3197
|
+
}
|
|
3198
|
+
if (last === null)
|
|
3199
|
+
throw new Error(`Request to ${rel} completed without a result`);
|
|
3200
|
+
throw last.error;
|
|
3201
|
+
}
|
|
3202
|
+
return {
|
|
3203
|
+
baseUrl: base,
|
|
3204
|
+
request,
|
|
3205
|
+
get: (path, opts) => request("GET", path, undefined, opts),
|
|
3206
|
+
post: (path, body, opts) => request("POST", path, body, opts),
|
|
3207
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
3208
|
+
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
3209
|
+
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3212
|
+
function newIdempotencyKey2() {
|
|
3213
|
+
const g = globalThis;
|
|
3214
|
+
if (g.crypto?.randomUUID)
|
|
3215
|
+
return g.crypto.randomUUID();
|
|
3216
|
+
return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
3217
|
+
}
|
|
3218
|
+
function extractItems2(raw, extraKeys = []) {
|
|
3219
|
+
if (Array.isArray(raw))
|
|
3220
|
+
return raw;
|
|
3221
|
+
if (raw && typeof raw === "object") {
|
|
3222
|
+
const obj = raw;
|
|
3223
|
+
for (const key of [...extraKeys, "items", "data", "results", "rows", "records"]) {
|
|
3224
|
+
if (Array.isArray(obj[key]))
|
|
3225
|
+
return obj[key];
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
return [];
|
|
3229
|
+
}
|
|
3230
|
+
function createStorageClient(name, transport) {
|
|
3231
|
+
const rp = (r) => `/${r.replace(/^\/+|\/+$/g, "")}`;
|
|
3232
|
+
const ep = (r, id) => `${rp(r)}/${encodeURIComponent(String(id))}`;
|
|
3233
|
+
return {
|
|
3234
|
+
name,
|
|
3235
|
+
baseUrl: transport.baseUrl,
|
|
3236
|
+
transport,
|
|
3237
|
+
async list(resource, query) {
|
|
3238
|
+
const raw = await transport.get(rp(resource), { query });
|
|
3239
|
+
return { items: extractItems2(raw, [resource]), raw };
|
|
3240
|
+
},
|
|
1476
3241
|
async get(resource, id) {
|
|
1477
3242
|
try {
|
|
1478
3243
|
return await transport.get(ep(resource, id));
|
|
1479
3244
|
} catch (error) {
|
|
1480
|
-
if (error instanceof
|
|
3245
|
+
if (error instanceof HasnaHttpError3 && error.status === 404)
|
|
1481
3246
|
return null;
|
|
1482
3247
|
throw error;
|
|
1483
3248
|
}
|
|
1484
3249
|
},
|
|
1485
3250
|
async create(resource, body, idempotencyKey) {
|
|
1486
|
-
return transport.post(rp(resource), body, { idempotencyKey: idempotencyKey ??
|
|
3251
|
+
return transport.post(rp(resource), body, { idempotencyKey: idempotencyKey ?? newIdempotencyKey2() });
|
|
1487
3252
|
},
|
|
1488
3253
|
async update(resource, id, patch, method = "PATCH") {
|
|
1489
3254
|
const call = method === "PUT" ? transport.put : transport.patch;
|
|
@@ -1493,27 +3258,42 @@ function createStorageClient(name, transport) {
|
|
|
1493
3258
|
try {
|
|
1494
3259
|
await transport.del(ep(resource, id));
|
|
1495
3260
|
} catch (error) {
|
|
1496
|
-
if (error instanceof
|
|
3261
|
+
if (error instanceof HasnaHttpError3 && error.status === 404)
|
|
1497
3262
|
return;
|
|
1498
3263
|
throw error;
|
|
1499
3264
|
}
|
|
1500
3265
|
}
|
|
1501
3266
|
};
|
|
1502
3267
|
}
|
|
1503
|
-
function
|
|
3268
|
+
function resolveStoreClient(name, env = process.env) {
|
|
1504
3269
|
const resolution = resolveTransport(name, env);
|
|
1505
3270
|
if (resolution.misconfigured) {
|
|
3271
|
+
const wired2 = createClientTransport(name, env);
|
|
3272
|
+
if (wired2.transport === "http") {
|
|
3273
|
+
return {
|
|
3274
|
+
transport: "http",
|
|
3275
|
+
client: createHasnaStorageClient(name, wired2.client),
|
|
3276
|
+
resolution: {
|
|
3277
|
+
transport: "http",
|
|
3278
|
+
requested: "http",
|
|
3279
|
+
modeSource: resolution.modeSource === "default" ? "auto:api-url+seam-credential" : resolution.modeSource,
|
|
3280
|
+
baseUrl: wired2.resolution.baseUrl,
|
|
3281
|
+
apiKeyPresent: true,
|
|
3282
|
+
misconfigured: false,
|
|
3283
|
+
warning: null
|
|
3284
|
+
}
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
1506
3287
|
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
|
|
1507
3288
|
}
|
|
1508
3289
|
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
1509
3290
|
return { transport: "sqlite", client: null, resolution };
|
|
1510
3291
|
}
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1513
|
-
if (!apiKey)
|
|
3292
|
+
const wired = createClientTransport(name, env);
|
|
3293
|
+
if (wired.transport !== "http") {
|
|
1514
3294
|
throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
|
|
1515
|
-
|
|
1516
|
-
return { transport: "http", client:
|
|
3295
|
+
}
|
|
3296
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client), resolution };
|
|
1517
3297
|
}
|
|
1518
3298
|
|
|
1519
3299
|
// src/store.ts
|
|
@@ -1596,6 +3376,22 @@ var localStore = {
|
|
|
1596
3376
|
await withLocalStoreReaderLease(() => saveFeedback(input));
|
|
1597
3377
|
}
|
|
1598
3378
|
};
|
|
3379
|
+
async function listResource(client, resource, query) {
|
|
3380
|
+
const raw = await client.transport.get(`/${resource}`, query ? { query } : undefined);
|
|
3381
|
+
return { items: extractEnvelopeItems(raw, resource), raw };
|
|
3382
|
+
}
|
|
3383
|
+
function extractEnvelopeItems(raw, resource) {
|
|
3384
|
+
if (Array.isArray(raw))
|
|
3385
|
+
return raw;
|
|
3386
|
+
if (raw && typeof raw === "object") {
|
|
3387
|
+
const obj = raw;
|
|
3388
|
+
for (const key of [resource, "items", "data", "results", "rows", "records"]) {
|
|
3389
|
+
if (Array.isArray(obj[key]))
|
|
3390
|
+
return obj[key];
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
return [];
|
|
3394
|
+
}
|
|
1599
3395
|
function apiStore(client) {
|
|
1600
3396
|
return {
|
|
1601
3397
|
mode: "http",
|
|
@@ -1603,7 +3399,7 @@ function apiStore(client) {
|
|
|
1603
3399
|
async createRecording(input, idempotencyKey) {
|
|
1604
3400
|
const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID2() : idempotencyKey;
|
|
1605
3401
|
const identity = recordingCreateIdentity(input, keyCandidate, { bindIdempotencyKeyToId: false });
|
|
1606
|
-
const res = await client.create("recordings", identity.input, identity.idempotencyKey);
|
|
3402
|
+
const res = await client.create("recordings", identity.input, { idempotencyKey: identity.idempotencyKey });
|
|
1607
3403
|
return unwrap(res, "recording");
|
|
1608
3404
|
},
|
|
1609
3405
|
async getRecording(id) {
|
|
@@ -1611,7 +3407,7 @@ function apiStore(client) {
|
|
|
1611
3407
|
return res ? unwrap(res, "recording") : null;
|
|
1612
3408
|
},
|
|
1613
3409
|
async listRecordings(filter) {
|
|
1614
|
-
const { items } = await client
|
|
3410
|
+
const { items } = await listResource(client, "recordings", listQuery(filter));
|
|
1615
3411
|
return items;
|
|
1616
3412
|
},
|
|
1617
3413
|
async countRecordings(filter) {
|
|
@@ -1622,7 +3418,7 @@ function apiStore(client) {
|
|
|
1622
3418
|
const seenPageKeys = new Set;
|
|
1623
3419
|
while (pageRequests < maxPageRequests) {
|
|
1624
3420
|
pageRequests += 1;
|
|
1625
|
-
const { items, raw } = await client
|
|
3421
|
+
const { items, raw } = await listResource(client, "recordings", {
|
|
1626
3422
|
...listQuery(filter),
|
|
1627
3423
|
limit: pageLimit,
|
|
1628
3424
|
offset
|
|
@@ -1645,7 +3441,7 @@ function apiStore(client) {
|
|
|
1645
3441
|
throw new Error(`Recordings API exceeded ${maxPageRequests} pages while counting legacy results`);
|
|
1646
3442
|
},
|
|
1647
3443
|
async searchRecordings(query, filter) {
|
|
1648
|
-
const { items } = await client
|
|
3444
|
+
const { items } = await listResource(client, "recordings", listQuery({ ...filter ?? {}, search: query }));
|
|
1649
3445
|
return items;
|
|
1650
3446
|
},
|
|
1651
3447
|
async deleteRecording(id) {
|
|
@@ -1677,7 +3473,7 @@ function apiStore(client) {
|
|
|
1677
3473
|
return res ? unwrap(res, "agent") : null;
|
|
1678
3474
|
},
|
|
1679
3475
|
async listAgents() {
|
|
1680
|
-
const { items } = await client
|
|
3476
|
+
const { items } = await listResource(client, "agents");
|
|
1681
3477
|
return items;
|
|
1682
3478
|
},
|
|
1683
3479
|
async heartbeatAgent(idOrName) {
|
|
@@ -1717,7 +3513,7 @@ function apiStore(client) {
|
|
|
1717
3513
|
return res ? unwrap(res, "project") : null;
|
|
1718
3514
|
},
|
|
1719
3515
|
async listProjects() {
|
|
1720
|
-
const { items } = await client
|
|
3516
|
+
const { items } = await listResource(client, "projects");
|
|
1721
3517
|
return items;
|
|
1722
3518
|
},
|
|
1723
3519
|
async saveFeedback(input) {
|
|
@@ -1740,7 +3536,7 @@ var cached = null;
|
|
|
1740
3536
|
function getStore(env = process.env) {
|
|
1741
3537
|
if (env === process.env && cached)
|
|
1742
3538
|
return cached;
|
|
1743
|
-
const resolved =
|
|
3539
|
+
const resolved = resolveStoreClient(APP, env);
|
|
1744
3540
|
const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
|
|
1745
3541
|
if (env === process.env)
|
|
1746
3542
|
cached = store;
|
|
@@ -2031,7 +3827,7 @@ function combinePrompts(...prompts) {
|
|
|
2031
3827
|
}
|
|
2032
3828
|
// src/lib/recorder.ts
|
|
2033
3829
|
import { spawn } from "child_process";
|
|
2034
|
-
import { join as
|
|
3830
|
+
import { join as join5 } from "path";
|
|
2035
3831
|
import { existsSync as existsSync2 } from "fs";
|
|
2036
3832
|
var _recordProcess = null;
|
|
2037
3833
|
var _currentFile = null;
|
|
@@ -2058,7 +3854,7 @@ function startRecording(config) {
|
|
|
2058
3854
|
}
|
|
2059
3855
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2060
3856
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
2061
|
-
const filepath =
|
|
3857
|
+
const filepath = join5(config.audio_dir, filename);
|
|
2062
3858
|
const args = buildRecordArgs(filepath, config);
|
|
2063
3859
|
const [command, ...commandArgs] = args;
|
|
2064
3860
|
if (command === undefined) {
|
|
@@ -2136,7 +3932,7 @@ function buildRecordArgs(filepath, config) {
|
|
|
2136
3932
|
async function recordDuration(seconds, config) {
|
|
2137
3933
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2138
3934
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
2139
|
-
const filepath =
|
|
3935
|
+
const filepath = join5(config.audio_dir, filename);
|
|
2140
3936
|
const args = [
|
|
2141
3937
|
"rec",
|
|
2142
3938
|
"-r",
|
|
@@ -2168,8 +3964,8 @@ async function recordDuration(seconds, config) {
|
|
|
2168
3964
|
}
|
|
2169
3965
|
// src/lib/capture-probe.ts
|
|
2170
3966
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
2171
|
-
import { existsSync as existsSync3, readFileSync as
|
|
2172
|
-
import { join as
|
|
3967
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, rmSync as rmSync2 } from "fs";
|
|
3968
|
+
import { join as join6 } from "path";
|
|
2173
3969
|
import { tmpdir } from "os";
|
|
2174
3970
|
|
|
2175
3971
|
// src/lib/macos-bundle.ts
|
|
@@ -2185,7 +3981,7 @@ var WAVE_FORMAT_PCM = 1;
|
|
|
2185
3981
|
var WAVE_FORMAT_EXTENSIBLE = 65534;
|
|
2186
3982
|
var SUBFORMAT_OFFSET_IN_EXTENSION = 8;
|
|
2187
3983
|
function readWavPeak(filepath) {
|
|
2188
|
-
const buf =
|
|
3984
|
+
const buf = readFileSync4(filepath);
|
|
2189
3985
|
if (buf.length < RIFF_HEADER_BYTES) {
|
|
2190
3986
|
throw new Error(`not a RIFF file (${buf.length} bytes): ${filepath}`);
|
|
2191
3987
|
}
|
|
@@ -2260,7 +4056,7 @@ function probeMicrophoneCapture(config, options = {}) {
|
|
|
2260
4056
|
peak: 0,
|
|
2261
4057
|
silent: null
|
|
2262
4058
|
};
|
|
2263
|
-
const filepath =
|
|
4059
|
+
const filepath = join6(tmpdir(), `recordings-capture-probe-${process.pid}-${Date.now()}.wav`);
|
|
2264
4060
|
try {
|
|
2265
4061
|
const result = spawnSync2(executable, [
|
|
2266
4062
|
"-q",
|
|
@@ -2360,37 +4156,37 @@ function captureProbeSubject(env = process.env, options = {}) {
|
|
|
2360
4156
|
const termProgram = env.TERM_PROGRAM?.trim();
|
|
2361
4157
|
const hasTty = options.hasTty ?? Boolean(process.stdin.isTTY || process.stdout.isTTY);
|
|
2362
4158
|
if (overSsh) {
|
|
2363
|
-
const subject2 = "the SSH session (sshd), not
|
|
4159
|
+
const subject2 = "the SSH session (sshd), not HasnaRecordings.app";
|
|
2364
4160
|
return {
|
|
2365
4161
|
headless: true,
|
|
2366
4162
|
subject_known: true,
|
|
2367
4163
|
subject: subject2,
|
|
2368
|
-
note: "Running over SSH: macOS cannot display a consent prompt to a session with no GUI, " + "so Microphone stays not_determined and a silent capture here says NOTHING about " + "whether
|
|
4164
|
+
note: "Running over SSH: macOS cannot display a consent prompt to a session with no GUI, " + "so Microphone stays not_determined and a silent capture here says NOTHING about " + "whether HasnaRecordings.app can record. Judge the app by its own TCC entry and its log."
|
|
2369
4165
|
};
|
|
2370
4166
|
}
|
|
2371
4167
|
if (inTmux) {
|
|
2372
|
-
const subject2 = "the tmux server, not
|
|
4168
|
+
const subject2 = "the tmux server, not HasnaRecordings.app and not this pane's shell";
|
|
2373
4169
|
return {
|
|
2374
4170
|
headless: false,
|
|
2375
4171
|
subject_known: true,
|
|
2376
4172
|
subject: subject2,
|
|
2377
|
-
note: "Running inside tmux: TCC attributes this capture to the tmux binary, which holds its " + "own grant. tmux also snapshots the environment at pane creation, so SSH variables may " + "be missing even in a remote session \u2014 treat a silent result as inconclusive about both " + "
|
|
4173
|
+
note: "Running inside tmux: TCC attributes this capture to the tmux binary, which holds its " + "own grant. tmux also snapshots the environment at pane creation, so SSH variables may " + "be missing even in a remote session \u2014 treat a silent result as inconclusive about both " + "HasnaRecordings.app and about whether anyone could have been prompted."
|
|
2378
4174
|
};
|
|
2379
4175
|
}
|
|
2380
4176
|
if (!termProgram && !hasTty) {
|
|
2381
4177
|
return {
|
|
2382
4178
|
headless: true,
|
|
2383
4179
|
subject_known: false,
|
|
2384
|
-
subject: "an unidentified responsible process (not
|
|
2385
|
-
note: "The responsible process could not be identified: no SSH variables, no TERM_PROGRAM and " + "no tty, which is what launchd, cron, CI and sudo look like. Whatever holds the grant, it " + "is not
|
|
4180
|
+
subject: "an unidentified responsible process (not HasnaRecordings.app)",
|
|
4181
|
+
note: "The responsible process could not be identified: no SSH variables, no TERM_PROGRAM and " + "no tty, which is what launchd, cron, CI and sudo look like. Whatever holds the grant, it " + "is not HasnaRecordings.app, and nothing here can be shown a consent prompt. Inconclusive."
|
|
2386
4182
|
};
|
|
2387
4183
|
}
|
|
2388
|
-
const subject = `${termProgram || "the terminal application running this command"}, not
|
|
4184
|
+
const subject = `${termProgram || "the terminal application running this command"}, not HasnaRecordings.app`;
|
|
2389
4185
|
return {
|
|
2390
4186
|
headless: false,
|
|
2391
4187
|
subject_known: Boolean(termProgram),
|
|
2392
4188
|
subject,
|
|
2393
|
-
note: `Grants are per responsible process: this probe exercises ${subject}. ` + "A pass proves the microphone hardware and the input device work; it does not " + "transfer to
|
|
4189
|
+
note: `Grants are per responsible process: this probe exercises ${subject}. ` + "A pass proves the microphone hardware and the input device work; it does not " + "transfer to HasnaRecordings.app, which needs its own grant."
|
|
2394
4190
|
};
|
|
2395
4191
|
}
|
|
2396
4192
|
var TCC_UNREADABLE_STATE = TCC_DATABASE_UNREADABLE_STATE;
|
|
@@ -2420,7 +4216,7 @@ function microphoneGrantInstruction(options) {
|
|
|
2420
4216
|
const bundlePath = candidates[0] ?? null;
|
|
2421
4217
|
const steps = [];
|
|
2422
4218
|
if (!bundlePath) {
|
|
2423
|
-
steps.push("No
|
|
4219
|
+
steps.push("No HasnaRecordings.app bundle was found on disk, so there is nothing to grant Microphone to yet. " + "Install the app first ('recordings app install').");
|
|
2424
4220
|
return {
|
|
2425
4221
|
bundle_path: null,
|
|
2426
4222
|
bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
@@ -2429,10 +4225,10 @@ function microphoneGrantInstruction(options) {
|
|
|
2429
4225
|
};
|
|
2430
4226
|
}
|
|
2431
4227
|
if (candidates.length > 1) {
|
|
2432
|
-
steps.push(`AMBIGUOUS: ${candidates.length}
|
|
4228
|
+
steps.push(`AMBIGUOUS: ${candidates.length} HasnaRecordings.app bundles exist (${candidates.join(", ")}). ` + "A TCC grant is bound to the bundle's code signature, so granting one does not grant the " + "other, and the toggle in Settings does not say which is which. Remove the bundles you are " + "not running before granting, or the grant may attach to the wrong one.");
|
|
2433
4229
|
}
|
|
2434
4230
|
steps.push(`At the keyboard on the machine itself (not over SSH), launch ${bundlePath} and start a ` + "recording once. macOS shows the consent sheet titled " + `"\u201CRecordings\u201D would like to access the microphone" \u2014 click Allow.`);
|
|
2435
|
-
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${
|
|
4231
|
+
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${join6(bundlePath, "Contents", "MacOS", "Recordings")}.`);
|
|
2436
4232
|
if (options.requestState === "never_requested") {
|
|
2437
4233
|
steps.push("Note: the app has never requested microphone access on this machine (no TCC entry exists), " + "so the Microphone list will NOT contain a \u201CRecordings\u201D row until the app asks once. " + "Do the launch-and-record step first; the Settings toggle only exists afterwards.");
|
|
2438
4234
|
} else if (options.requestState === "unknown") {
|
|
@@ -2512,7 +4308,7 @@ function describeActiveStore(config, env = process.env) {
|
|
|
2512
4308
|
warnings.push(`writes go to ${safeBaseUrl(resolution.baseUrl)}, but ${localDbPath} still holds ` + `${localDbRecordings} recordings from an earlier on-box-only period. ` + "That file is NOT the live store \u2014 auditing it undercounts and looks like data loss.");
|
|
2513
4309
|
}
|
|
2514
4310
|
if (resolution.transport === "http" && resolution.modeSource === AUTO_FLIP_MODE_SOURCE) {
|
|
2515
|
-
warnings.push("the API transport was selected by the mere PRESENCE of " + "HASNA_RECORDINGS_API_URL + HASNA_RECORDINGS_API_KEY
|
|
4311
|
+
warnings.push("the API transport was selected by the mere PRESENCE of " + "HASNA_RECORDINGS_API_URL + HASNA_RECORDINGS_API_KEY. " + "Check `launchctl getenv HASNA_RECORDINGS_API_URL` too: a launchd session variable " + "is inherited by the GUI app as well as by shells, and is invisible in a login profile.");
|
|
2516
4312
|
}
|
|
2517
4313
|
return {
|
|
2518
4314
|
transport: resolution.transport,
|
|
@@ -2713,7 +4509,7 @@ export {
|
|
|
2713
4509
|
PERSISTENCE_PROBE_TAG,
|
|
2714
4510
|
PERSISTENCE_PROBE_MARKER_PREFIX,
|
|
2715
4511
|
MAX_PROBE_SECONDS,
|
|
2716
|
-
HasnaHttpError,
|
|
4512
|
+
HasnaHttpError3 as HasnaHttpError,
|
|
2717
4513
|
EnhancementError,
|
|
2718
4514
|
DEFAULT_TRANSCRIPTION_MODEL,
|
|
2719
4515
|
DEFAULT_RECORD_EXECUTABLE,
|