@hasna/recordings 0.3.13 → 0.4.0
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 +223 -37
- package/bun.lock +37 -38
- package/dist/cli/index.js +1884 -615
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2858 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- 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/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1689 -456
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +566 -284
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1334 -871
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +9 -8
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +20 -16
- package/scripts/migrate.ts +4 -20
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- 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 +6 -6
- package/src/native/Recordings/App/SidebarView.swift +0 -212
package/dist/mcp/index.js
CHANGED
|
@@ -4246,6 +4246,9 @@ var DEFAULT_CONFIG = {
|
|
|
4246
4246
|
db_path: "",
|
|
4247
4247
|
audio_dir: "",
|
|
4248
4248
|
max_recording_seconds: 1800,
|
|
4249
|
+
s3_bucket: "",
|
|
4250
|
+
s3_prefix: "",
|
|
4251
|
+
s3_region: "",
|
|
4249
4252
|
config_warnings: []
|
|
4250
4253
|
};
|
|
4251
4254
|
function loadConfig(configPath) {
|
|
@@ -4272,6 +4275,9 @@ function loadConfig(configPath) {
|
|
|
4272
4275
|
if (process.env.RECORDINGS_API_KEY) {
|
|
4273
4276
|
config.openai_api_key = process.env.RECORDINGS_API_KEY;
|
|
4274
4277
|
}
|
|
4278
|
+
if (process.env.RECORDINGS_OPENAI_API_KEY) {
|
|
4279
|
+
config.openai_api_key = process.env.RECORDINGS_OPENAI_API_KEY;
|
|
4280
|
+
}
|
|
4275
4281
|
if (process.env.RECORDINGS_ENHANCEMENT_KEY) {
|
|
4276
4282
|
config.enhancement_api_key = process.env.RECORDINGS_ENHANCEMENT_KEY;
|
|
4277
4283
|
}
|
|
@@ -4315,14 +4321,26 @@ function loadConfig(configPath) {
|
|
|
4315
4321
|
if (process.env.RECORDINGS_AUDIO_DIR) {
|
|
4316
4322
|
config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
|
|
4317
4323
|
}
|
|
4324
|
+
if (process.env.HASNA_RECORDINGS_S3_BUCKET) {
|
|
4325
|
+
config.s3_bucket = process.env.HASNA_RECORDINGS_S3_BUCKET;
|
|
4326
|
+
} else if (process.env.RECORDINGS_S3_BUCKET) {
|
|
4327
|
+
config.s3_bucket = process.env.RECORDINGS_S3_BUCKET;
|
|
4328
|
+
}
|
|
4329
|
+
if (process.env.HASNA_RECORDINGS_S3_PREFIX) {
|
|
4330
|
+
config.s3_prefix = process.env.HASNA_RECORDINGS_S3_PREFIX;
|
|
4331
|
+
} else if (process.env.RECORDINGS_S3_PREFIX) {
|
|
4332
|
+
config.s3_prefix = process.env.RECORDINGS_S3_PREFIX;
|
|
4333
|
+
}
|
|
4334
|
+
if (process.env.RECORDINGS_S3_REGION) {
|
|
4335
|
+
config.s3_region = process.env.RECORDINGS_S3_REGION;
|
|
4336
|
+
} else if (process.env.AWS_REGION) {
|
|
4337
|
+
config.s3_region = process.env.AWS_REGION;
|
|
4338
|
+
}
|
|
4318
4339
|
if (process.env.RECORDINGS_MAX_SECONDS) {
|
|
4319
4340
|
config.max_recording_seconds = parseInt(process.env.RECORDINGS_MAX_SECONDS, 10);
|
|
4320
4341
|
}
|
|
4321
|
-
if (!config.openai_api_key) {
|
|
4322
|
-
config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
|
|
4323
|
-
}
|
|
4324
4342
|
if (!config.enhancement_api_key) {
|
|
4325
|
-
config.enhancement_api_key = config.openai_api_key
|
|
4343
|
+
config.enhancement_api_key = config.openai_api_key;
|
|
4326
4344
|
}
|
|
4327
4345
|
if (!explicitTranscriberModel) {
|
|
4328
4346
|
config.transcriber_model = config.enhancement_model;
|
|
@@ -4502,51 +4520,6 @@ function mergeDirectoryContents(sourceDir, targetDir) {
|
|
|
4502
4520
|
}
|
|
4503
4521
|
}
|
|
4504
4522
|
}
|
|
4505
|
-
function loadSecretKey(keyName) {
|
|
4506
|
-
const secretsPath = join2(getHomeDir(), ".secrets");
|
|
4507
|
-
if (!existsSync(secretsPath))
|
|
4508
|
-
return "";
|
|
4509
|
-
for (const candidate of listSecretFiles(secretsPath)) {
|
|
4510
|
-
try {
|
|
4511
|
-
const content = readFileSync(candidate, "utf-8");
|
|
4512
|
-
const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
|
|
4513
|
-
const doubleQuotedValue = match?.[1];
|
|
4514
|
-
if (doubleQuotedValue !== undefined)
|
|
4515
|
-
return doubleQuotedValue;
|
|
4516
|
-
const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
|
|
4517
|
-
const singleQuotedValue = match2?.[1];
|
|
4518
|
-
if (singleQuotedValue !== undefined)
|
|
4519
|
-
return singleQuotedValue;
|
|
4520
|
-
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
4521
|
-
const unquotedValue = match3?.[1];
|
|
4522
|
-
if (unquotedValue !== undefined)
|
|
4523
|
-
return unquotedValue.trim().replace(/^["']|["']$/g, "");
|
|
4524
|
-
} catch {}
|
|
4525
|
-
}
|
|
4526
|
-
return "";
|
|
4527
|
-
}
|
|
4528
|
-
function listSecretFiles(path) {
|
|
4529
|
-
try {
|
|
4530
|
-
const stats = statSync(path);
|
|
4531
|
-
if (stats.isFile())
|
|
4532
|
-
return [path];
|
|
4533
|
-
if (!stats.isDirectory())
|
|
4534
|
-
return [];
|
|
4535
|
-
return readdirSync(path).sort().flatMap((entry) => {
|
|
4536
|
-
const child = join2(path, entry);
|
|
4537
|
-
try {
|
|
4538
|
-
const childStats = statSync(child);
|
|
4539
|
-
if (childStats.isDirectory())
|
|
4540
|
-
return listSecretFiles(child);
|
|
4541
|
-
if (childStats.isFile() && child.endsWith(".env"))
|
|
4542
|
-
return [child];
|
|
4543
|
-
} catch {}
|
|
4544
|
-
return [];
|
|
4545
|
-
});
|
|
4546
|
-
} catch {
|
|
4547
|
-
return [];
|
|
4548
|
-
}
|
|
4549
|
-
}
|
|
4550
4523
|
function getHomeDir() {
|
|
4551
4524
|
return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
|
|
4552
4525
|
}
|
|
@@ -4676,6 +4649,11 @@ var MIGRATIONS = [
|
|
|
4676
4649
|
`,
|
|
4677
4650
|
`
|
|
4678
4651
|
ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
|
4652
|
+
`,
|
|
4653
|
+
`
|
|
4654
|
+
ALTER TABLE recordings ADD COLUMN audio_object_key TEXT;
|
|
4655
|
+
ALTER TABLE recordings ADD COLUMN audio_sha256 TEXT;
|
|
4656
|
+
ALTER TABLE recordings ADD COLUMN audio_bytes INTEGER;
|
|
4679
4657
|
`
|
|
4680
4658
|
];
|
|
4681
4659
|
var CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
|
|
@@ -4731,6 +4709,9 @@ function repairSchemaDrift(db) {
|
|
|
4731
4709
|
ensureColumn(db, "recordings", "task_list_id", "TEXT");
|
|
4732
4710
|
ensureColumn(db, "recordings", "machine_id", "TEXT");
|
|
4733
4711
|
ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
|
|
4712
|
+
ensureColumn(db, "recordings", "audio_object_key", "TEXT");
|
|
4713
|
+
ensureColumn(db, "recordings", "audio_sha256", "TEXT");
|
|
4714
|
+
ensureColumn(db, "recordings", "audio_bytes", "INTEGER");
|
|
4734
4715
|
ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
|
|
4735
4716
|
}
|
|
4736
4717
|
function ensureColumn(db, table, column, definition) {
|
|
@@ -4806,6 +4787,9 @@ function parseRow(row) {
|
|
|
4806
4787
|
return {
|
|
4807
4788
|
id: row["id"],
|
|
4808
4789
|
audio_path: row["audio_path"] || null,
|
|
4790
|
+
audio_object_key: row["audio_object_key"] || null,
|
|
4791
|
+
audio_sha256: row["audio_sha256"] || null,
|
|
4792
|
+
audio_bytes: Number(row["audio_bytes"] ?? 0) || null,
|
|
4809
4793
|
raw_text: row["raw_text"],
|
|
4810
4794
|
processed_text: row["processed_text"] || null,
|
|
4811
4795
|
processing_mode: row["processing_mode"] || "raw",
|
|
@@ -4837,9 +4821,9 @@ function createRecording(input, db, idempotencyKey) {
|
|
|
4837
4821
|
}
|
|
4838
4822
|
const tagsJson = JSON.stringify(input.tags || []);
|
|
4839
4823
|
const metadataJson = JSON.stringify(input.metadata || {});
|
|
4840
|
-
const insertResult = d.query(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
|
|
4841
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4842
|
-
ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
|
|
4824
|
+
const insertResult = d.query(`INSERT INTO recordings (id, audio_path, audio_object_key, audio_sha256, audio_bytes, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
|
|
4825
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4826
|
+
ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.audio_object_key || null, input.audio_sha256 || null, input.audio_bytes || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
|
|
4843
4827
|
if (insertResult.changes === 0) {
|
|
4844
4828
|
const existing = getRecording(id, d);
|
|
4845
4829
|
if (existing?.id === id)
|
|
@@ -4856,6 +4840,13 @@ function createRecording(input, db, idempotencyKey) {
|
|
|
4856
4840
|
});
|
|
4857
4841
|
return create();
|
|
4858
4842
|
}
|
|
4843
|
+
function updateRecordingAudio(id, audioObjectKey, audioSha256, audioBytes, db) {
|
|
4844
|
+
const d = db || getDatabase();
|
|
4845
|
+
const result = d.query(`UPDATE recordings SET audio_object_key = ?, audio_sha256 = ?, audio_bytes = ? WHERE id = ?`).run(audioObjectKey, audioSha256, audioBytes, id);
|
|
4846
|
+
if (result.changes === 0)
|
|
4847
|
+
return null;
|
|
4848
|
+
return getRecording(id, d);
|
|
4849
|
+
}
|
|
4859
4850
|
function getRecording(id, db) {
|
|
4860
4851
|
const d = db || getDatabase();
|
|
4861
4852
|
let row = d.query("SELECT * FROM recordings WHERE id = ?").get(id);
|
|
@@ -5057,7 +5048,7 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
5057
5048
|
// package.json
|
|
5058
5049
|
var package_default = {
|
|
5059
5050
|
name: "@hasna/recordings",
|
|
5060
|
-
version: "0.
|
|
5051
|
+
version: "0.4.0",
|
|
5061
5052
|
type: "module",
|
|
5062
5053
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
5063
5054
|
repository: {
|
|
@@ -5091,10 +5082,10 @@ var package_default = {
|
|
|
5091
5082
|
scripts: {
|
|
5092
5083
|
clean: "rm -rf dist",
|
|
5093
5084
|
build: "bun run clean && bun run build:cli && bun run build:mcp && bun run build:serve && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
5094
|
-
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
5095
|
-
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
5096
|
-
"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",
|
|
5097
|
-
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai",
|
|
5085
|
+
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai --external=@aws-sdk/client-s3",
|
|
5086
|
+
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai --external=@aws-sdk/client-s3",
|
|
5087
|
+
"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 --external=@aws-sdk/client-s3",
|
|
5088
|
+
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai --external=@aws-sdk/client-s3",
|
|
5098
5089
|
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
5099
5090
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
5100
5091
|
migrate: "bun run scripts/migrate.ts",
|
|
@@ -5104,7 +5095,7 @@ var package_default = {
|
|
|
5104
5095
|
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
5105
5096
|
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
5106
5097
|
"verify:ci-native": "bun scripts/ci-native-build.ts",
|
|
5107
|
-
"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)',
|
|
5098
|
+
"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:-120000}" $(bun scripts/ci-linux-suite.ts --gated)',
|
|
5108
5099
|
"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"',
|
|
5109
5100
|
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
5110
5101
|
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
@@ -5141,7 +5132,8 @@ var package_default = {
|
|
|
5141
5132
|
"LICENSE"
|
|
5142
5133
|
],
|
|
5143
5134
|
dependencies: {
|
|
5144
|
-
"@
|
|
5135
|
+
"@aws-sdk/client-s3": "^3.1007.0",
|
|
5136
|
+
"@hasna/contracts": "1.0.2",
|
|
5145
5137
|
"@hasna/events": "0.1.11",
|
|
5146
5138
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
5147
5139
|
chalk: "^5.4.1",
|
|
@@ -5151,7 +5143,7 @@ var package_default = {
|
|
|
5151
5143
|
zod: "^3.24.2"
|
|
5152
5144
|
},
|
|
5153
5145
|
devDependencies: {
|
|
5154
|
-
"@types/bun": "
|
|
5146
|
+
"@types/bun": "1.3.14",
|
|
5155
5147
|
"@types/pg": "^8.11.11",
|
|
5156
5148
|
"node-api-headers": "1.9.0",
|
|
5157
5149
|
typescript: "^5.8.2"
|
|
@@ -5175,8 +5167,12 @@ function saveFeedback(input) {
|
|
|
5175
5167
|
|
|
5176
5168
|
// ../contracts/dist/client/transport.js
|
|
5177
5169
|
import { isIP } from "net";
|
|
5178
|
-
import {
|
|
5179
|
-
import {
|
|
5170
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
5171
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync2 } from "fs";
|
|
5172
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
5173
|
+
import { createRequire } from "module";
|
|
5174
|
+
import { hostname as osHostname } from "os";
|
|
5175
|
+
import { isAbsolute as isAbsolute2, join as join3 } from "path";
|
|
5180
5176
|
function envToken(name) {
|
|
5181
5177
|
return name.toUpperCase().replace(/-/g, "_");
|
|
5182
5178
|
}
|
|
@@ -5191,6 +5187,9 @@ function credentialOverrideEnvKey(name) {
|
|
|
5191
5187
|
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
5192
5188
|
}
|
|
5193
5189
|
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
5190
|
+
function credentialPointerEnvKey(name) {
|
|
5191
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
5192
|
+
}
|
|
5194
5193
|
|
|
5195
5194
|
class CredentialResolutionError extends Error {
|
|
5196
5195
|
appName;
|
|
@@ -5202,34 +5201,70 @@ class CredentialResolutionError extends Error {
|
|
|
5202
5201
|
this.attempted = attempted;
|
|
5203
5202
|
}
|
|
5204
5203
|
}
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5204
|
+
|
|
5205
|
+
class CredentialFileUnsafeError extends Error {
|
|
5206
|
+
path;
|
|
5207
|
+
constructor(path, reason) {
|
|
5208
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
5209
|
+
this.name = "CredentialFileUnsafeError";
|
|
5210
|
+
this.path = path;
|
|
5211
|
+
}
|
|
5212
|
+
}
|
|
5213
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
5214
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
5215
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
5216
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
5217
|
+
var CONFIG_SUBDIR = "config";
|
|
5218
|
+
var CREDENTIALS_FILE = "credentials";
|
|
5219
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
5220
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
5221
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
5222
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
5209
5223
|
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
5210
5224
|
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
5211
5225
|
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
5212
5226
|
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
5227
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
5213
5228
|
function homeDir(env) {
|
|
5214
5229
|
const home = env.HOME?.trim();
|
|
5215
5230
|
return home ? home : null;
|
|
5216
5231
|
}
|
|
5217
|
-
function
|
|
5218
|
-
|
|
5232
|
+
function absoluteOverride(env, key) {
|
|
5233
|
+
const value = env[key]?.trim();
|
|
5234
|
+
return value && isAbsolute2(value) ? value : null;
|
|
5219
5235
|
}
|
|
5220
|
-
function
|
|
5236
|
+
function hasnaHomeDir(env) {
|
|
5237
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
5238
|
+
if (override)
|
|
5239
|
+
return override;
|
|
5221
5240
|
const home = homeDir(env);
|
|
5222
|
-
|
|
5241
|
+
return home ? join3(home, HASNA_HOME_DIR) : null;
|
|
5242
|
+
}
|
|
5243
|
+
function appConfigDir(name, env) {
|
|
5244
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
5245
|
+
if (configRoot)
|
|
5246
|
+
return join3(configRoot, name);
|
|
5247
|
+
const root = hasnaHomeDir(env);
|
|
5248
|
+
return root ? join3(root, name, CONFIG_SUBDIR) : null;
|
|
5249
|
+
}
|
|
5250
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
5251
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
5223
5252
|
return [];
|
|
5224
|
-
const
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5253
|
+
const directory = appConfigDir(name, env);
|
|
5254
|
+
if (!directory)
|
|
5255
|
+
return [];
|
|
5256
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
5257
|
+
return [{ path: join3(directory, file), tier: "disk" }];
|
|
5258
|
+
}
|
|
5259
|
+
function credentialDiskSources(name, env) {
|
|
5260
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
5261
|
+
}
|
|
5262
|
+
function profileDiskSources(name, env, profile) {
|
|
5263
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
5230
5264
|
}
|
|
5231
5265
|
function parseEnvFile(text) {
|
|
5232
5266
|
const values = new Map;
|
|
5267
|
+
const unusable = new Set;
|
|
5233
5268
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
5234
5269
|
const line = rawLine.trim();
|
|
5235
5270
|
if (line.length === 0 || line.startsWith("#"))
|
|
@@ -5244,38 +5279,81 @@ function parseEnvFile(text) {
|
|
|
5244
5279
|
let value = withoutExport.slice(equals + 1).trim();
|
|
5245
5280
|
const quote = value[0];
|
|
5246
5281
|
if (quote === '"' || quote === "'") {
|
|
5247
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
5282
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
5283
|
+
unusable.add(key);
|
|
5248
5284
|
continue;
|
|
5285
|
+
}
|
|
5249
5286
|
value = value.slice(1, -1);
|
|
5250
5287
|
}
|
|
5251
|
-
if (value.length === 0)
|
|
5288
|
+
if (value.trim().length === 0) {
|
|
5289
|
+
unusable.add(key);
|
|
5252
5290
|
continue;
|
|
5291
|
+
}
|
|
5292
|
+
if (values.has(key) && values.get(key) !== value)
|
|
5293
|
+
unusable.add(key);
|
|
5253
5294
|
values.set(key, value);
|
|
5254
5295
|
}
|
|
5255
|
-
return values;
|
|
5296
|
+
return { values, unusable };
|
|
5297
|
+
}
|
|
5298
|
+
function configFileModeAllowed(mode) {
|
|
5299
|
+
const permissions = mode & 4095;
|
|
5300
|
+
return permissions === 256 || permissions === 384;
|
|
5301
|
+
}
|
|
5302
|
+
function configFileReadsCoherent(before, after) {
|
|
5303
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
5256
5304
|
}
|
|
5257
5305
|
function readAppConfigFile(path) {
|
|
5258
|
-
|
|
5306
|
+
const unsafe = (reason) => {
|
|
5307
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
5308
|
+
};
|
|
5309
|
+
let fd = -1;
|
|
5259
5310
|
try {
|
|
5260
|
-
|
|
5261
|
-
|
|
5311
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
5312
|
+
} catch (error) {
|
|
5313
|
+
const code = error.code;
|
|
5314
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
5262
5315
|
return null;
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5316
|
+
if (code === "ELOOP")
|
|
5317
|
+
unsafe("the path is a symlink");
|
|
5318
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
5319
|
+
}
|
|
5320
|
+
try {
|
|
5321
|
+
const before = fstatSync(fd);
|
|
5322
|
+
if (!before.isFile())
|
|
5323
|
+
unsafe("the path is not a regular file");
|
|
5324
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
5325
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
5326
|
+
}
|
|
5327
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
5328
|
+
if (uid !== undefined && before.uid !== uid)
|
|
5329
|
+
unsafe("the file is not owned by the current user");
|
|
5330
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
5331
|
+
unsafe("the file exceeds the size limit");
|
|
5332
|
+
const bytes = readFileSync2(fd);
|
|
5333
|
+
const after = fstatSync(fd);
|
|
5334
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
5335
|
+
unsafe("the file changed while being read");
|
|
5336
|
+
}
|
|
5337
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
5338
|
+
} finally {
|
|
5339
|
+
if (fd !== -1)
|
|
5340
|
+
closeSync(fd);
|
|
5266
5341
|
}
|
|
5267
|
-
return parseEnvFile(text);
|
|
5268
5342
|
}
|
|
5269
5343
|
function readCredentialFile(path, apiKeyKeys) {
|
|
5270
|
-
const
|
|
5271
|
-
if (!
|
|
5344
|
+
const parsed = readAppConfigFile(path);
|
|
5345
|
+
if (!parsed)
|
|
5272
5346
|
return null;
|
|
5273
5347
|
for (const key of apiKeyKeys) {
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5348
|
+
if (parsed.unusable.has(key)) {
|
|
5349
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
5350
|
+
}
|
|
5277
5351
|
}
|
|
5278
|
-
|
|
5352
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
5353
|
+
if (new Set(values).size > 1) {
|
|
5354
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
5355
|
+
}
|
|
5356
|
+
return values[0] ?? null;
|
|
5279
5357
|
}
|
|
5280
5358
|
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
5281
5359
|
function appConfigDiskValue(name, env, keys) {
|
|
@@ -5283,11 +5361,19 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
5283
5361
|
if (wanted.length === 0)
|
|
5284
5362
|
return null;
|
|
5285
5363
|
for (const path of credentialDiskSources(name, env)) {
|
|
5286
|
-
const
|
|
5287
|
-
if (!
|
|
5364
|
+
const parsed = readAppConfigFile(path);
|
|
5365
|
+
if (!parsed)
|
|
5288
5366
|
continue;
|
|
5367
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
5368
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
5369
|
+
}
|
|
5370
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
5371
|
+
if (new Set(values).size > 1)
|
|
5372
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
5289
5373
|
for (const key of wanted) {
|
|
5290
|
-
|
|
5374
|
+
if (parsed.unusable.has(key))
|
|
5375
|
+
return { key, value: "", path, unusable: true };
|
|
5376
|
+
const value = parsed.values.get(key)?.trim();
|
|
5291
5377
|
if (value)
|
|
5292
5378
|
return { key, value, path };
|
|
5293
5379
|
}
|
|
@@ -5295,20 +5381,21 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
5295
5381
|
return null;
|
|
5296
5382
|
}
|
|
5297
5383
|
function assertUsableCredential(appName, source, value) {
|
|
5384
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
5385
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
5386
|
+
}
|
|
5298
5387
|
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
5299
5388
|
return;
|
|
5300
5389
|
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]);
|
|
5301
5390
|
}
|
|
5302
5391
|
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
5303
5392
|
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
5304
|
-
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
5305
5393
|
function sealCredential(fields) {
|
|
5306
5394
|
const { apiKey } = fields;
|
|
5307
5395
|
const visible = {
|
|
5308
5396
|
tier: fields.tier,
|
|
5309
5397
|
source: fields.source,
|
|
5310
5398
|
deliberate: fields.deliberate,
|
|
5311
|
-
deprecated: fields.deprecated,
|
|
5312
5399
|
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
5313
5400
|
warning: fields.warning
|
|
5314
5401
|
};
|
|
@@ -5319,6 +5406,14 @@ function sealCredential(fields) {
|
|
|
5319
5406
|
writable: false,
|
|
5320
5407
|
configurable: false
|
|
5321
5408
|
});
|
|
5409
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
5410
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
5411
|
+
value: fields.pointerVaultKey,
|
|
5412
|
+
enumerable: false,
|
|
5413
|
+
writable: false,
|
|
5414
|
+
configurable: false
|
|
5415
|
+
});
|
|
5416
|
+
}
|
|
5322
5417
|
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
5323
5418
|
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
5324
5419
|
enumerable: false,
|
|
@@ -5333,198 +5428,1105 @@ function sealCredential(fields) {
|
|
|
5333
5428
|
});
|
|
5334
5429
|
return Object.freeze(sealed);
|
|
5335
5430
|
}
|
|
5431
|
+
function firstEnvValue(env, keys) {
|
|
5432
|
+
for (const key of keys) {
|
|
5433
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
5434
|
+
continue;
|
|
5435
|
+
const value = env[key]?.trim();
|
|
5436
|
+
if (value)
|
|
5437
|
+
return { key, value };
|
|
5438
|
+
}
|
|
5439
|
+
return null;
|
|
5440
|
+
}
|
|
5441
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
5442
|
+
function isAmbientEnvironment(env) {
|
|
5443
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
5444
|
+
}
|
|
5445
|
+
function defaultKeychainRunner(argv) {
|
|
5446
|
+
const result = spawnSync2(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
5447
|
+
encoding: "utf8",
|
|
5448
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
5449
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
5450
|
+
});
|
|
5451
|
+
return {
|
|
5452
|
+
status: result.status,
|
|
5453
|
+
stdout: result.stdout ?? "",
|
|
5454
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
5455
|
+
};
|
|
5456
|
+
}
|
|
5457
|
+
function keychainTierEnabled(env, options) {
|
|
5458
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
5459
|
+
return false;
|
|
5460
|
+
if (options.enabled !== undefined)
|
|
5461
|
+
return options.enabled;
|
|
5462
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
5463
|
+
}
|
|
5464
|
+
function keychainAccount(env, options) {
|
|
5465
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
5466
|
+
if (station)
|
|
5467
|
+
return station;
|
|
5468
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
5469
|
+
if (host)
|
|
5470
|
+
return host;
|
|
5471
|
+
const user = env.USER?.trim();
|
|
5472
|
+
return user || null;
|
|
5473
|
+
}
|
|
5474
|
+
function keychainFailureHint(text) {
|
|
5475
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
5476
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
5477
|
+
return clean ? `: ${clean}` : "";
|
|
5478
|
+
}
|
|
5479
|
+
function readKeychainItem(name, env, kind, options) {
|
|
5480
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
5481
|
+
return null;
|
|
5482
|
+
const account = keychainAccount(env, options);
|
|
5483
|
+
if (!account)
|
|
5484
|
+
return null;
|
|
5485
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
5486
|
+
const source = `keychain:${service}@${account}`;
|
|
5487
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
5488
|
+
let result;
|
|
5489
|
+
try {
|
|
5490
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
5491
|
+
} catch (error) {
|
|
5492
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
5493
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
5494
|
+
}
|
|
5495
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
5496
|
+
return null;
|
|
5497
|
+
if (result.status !== 0) {
|
|
5498
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
5499
|
+
}
|
|
5500
|
+
const value = result.stdout.trim();
|
|
5501
|
+
if (!value) {
|
|
5502
|
+
throw new CredentialResolutionError(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
5503
|
+
}
|
|
5504
|
+
return { value, source };
|
|
5505
|
+
}
|
|
5506
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
5507
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
5508
|
+
}
|
|
5509
|
+
function snapshotClientEnvironment(name, env) {
|
|
5510
|
+
const keys = clientTransportEnvKeys(name);
|
|
5511
|
+
const ambient = isAmbientEnvironment(env);
|
|
5512
|
+
const snapshot = Object.create(null);
|
|
5513
|
+
for (const key of [
|
|
5514
|
+
...keys.apiUrlKeys,
|
|
5515
|
+
...keys.apiKeyKeys,
|
|
5516
|
+
credentialOverrideEnvKey(name),
|
|
5517
|
+
credentialPointerEnvKey(name),
|
|
5518
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
5519
|
+
"HOME",
|
|
5520
|
+
HASNA_HOME_ENV_KEY,
|
|
5521
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
5522
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
5523
|
+
"USER"
|
|
5524
|
+
]) {
|
|
5525
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
5526
|
+
if (!descriptor)
|
|
5527
|
+
continue;
|
|
5528
|
+
if (!("value" in descriptor)) {
|
|
5529
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
5530
|
+
}
|
|
5531
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
5532
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
5533
|
+
}
|
|
5534
|
+
snapshot[key] = descriptor.value;
|
|
5535
|
+
}
|
|
5536
|
+
if (ambient) {
|
|
5537
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
5538
|
+
value: true,
|
|
5539
|
+
enumerable: false,
|
|
5540
|
+
writable: false,
|
|
5541
|
+
configurable: false
|
|
5542
|
+
});
|
|
5543
|
+
}
|
|
5544
|
+
return Object.freeze(snapshot);
|
|
5545
|
+
}
|
|
5546
|
+
function resolveCredential(name, env, options = {}) {
|
|
5547
|
+
env = snapshotClientEnvironment(name, env);
|
|
5548
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
5549
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
5550
|
+
if (options.apiKey !== undefined) {
|
|
5551
|
+
const explicitKey = options.apiKey.trim();
|
|
5552
|
+
if (!explicitKey) {
|
|
5553
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
5554
|
+
}
|
|
5555
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
5556
|
+
return sealCredential({
|
|
5557
|
+
apiKey: explicitKey,
|
|
5558
|
+
tier: "argument",
|
|
5559
|
+
source: "explicit apiKey argument",
|
|
5560
|
+
deliberate: true,
|
|
5561
|
+
diskCandidates: diskPaths,
|
|
5562
|
+
warning: null
|
|
5563
|
+
});
|
|
5564
|
+
}
|
|
5565
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
5566
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
5567
|
+
if (overrideRaw !== undefined) {
|
|
5568
|
+
const override = overrideRaw.trim();
|
|
5569
|
+
if (!override) {
|
|
5570
|
+
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]);
|
|
5571
|
+
}
|
|
5572
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
5573
|
+
return sealCredential({
|
|
5574
|
+
apiKey: override,
|
|
5575
|
+
tier: "override",
|
|
5576
|
+
source: overrideKeyName,
|
|
5577
|
+
deliberate: true,
|
|
5578
|
+
diskCandidates: diskPaths,
|
|
5579
|
+
warning: null
|
|
5580
|
+
});
|
|
5581
|
+
}
|
|
5582
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
5583
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
5584
|
+
if (pointerRaw !== undefined) {
|
|
5585
|
+
const pointer = pointerRaw.trim();
|
|
5586
|
+
if (!pointer) {
|
|
5587
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
5588
|
+
}
|
|
5589
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
5590
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
5591
|
+
}
|
|
5592
|
+
return sealCredential({
|
|
5593
|
+
apiKey: "",
|
|
5594
|
+
pointerVaultKey: pointer,
|
|
5595
|
+
tier: "pointer",
|
|
5596
|
+
source: pointerKeyName,
|
|
5597
|
+
deliberate: true,
|
|
5598
|
+
diskCandidates: diskPaths,
|
|
5599
|
+
warning: null
|
|
5600
|
+
});
|
|
5601
|
+
}
|
|
5602
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
5603
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
5604
|
+
}
|
|
5605
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
5606
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
5607
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
5608
|
+
}
|
|
5609
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
5610
|
+
if (profile) {
|
|
5611
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
5612
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
5613
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
5614
|
+
}
|
|
5615
|
+
const paths = profileDiskSources(name, env, profile);
|
|
5616
|
+
for (const path of paths) {
|
|
5617
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
5618
|
+
if (value) {
|
|
5619
|
+
assertUsableCredential(name, path, value);
|
|
5620
|
+
return sealCredential({
|
|
5621
|
+
apiKey: value,
|
|
5622
|
+
tier: "profile",
|
|
5623
|
+
source: path,
|
|
5624
|
+
deliberate: true,
|
|
5625
|
+
diskCandidates: paths,
|
|
5626
|
+
warning: null
|
|
5627
|
+
});
|
|
5628
|
+
}
|
|
5629
|
+
}
|
|
5630
|
+
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);
|
|
5631
|
+
}
|
|
5632
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
5633
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
5634
|
+
if (blankEnv) {
|
|
5635
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
5636
|
+
}
|
|
5637
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
5638
|
+
throw new CredentialResolutionError(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
5639
|
+
}
|
|
5640
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
5641
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
5642
|
+
if (keychainHit) {
|
|
5643
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
5644
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain 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;
|
|
5645
|
+
return sealCredential({
|
|
5646
|
+
apiKey: keychainHit.value,
|
|
5647
|
+
tier: "keychain",
|
|
5648
|
+
source: keychainHit.source,
|
|
5649
|
+
deliberate: false,
|
|
5650
|
+
diskCandidates: diskPaths,
|
|
5651
|
+
warning
|
|
5652
|
+
});
|
|
5653
|
+
}
|
|
5654
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
5655
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
5656
|
+
if (diskHits.length > 0) {
|
|
5657
|
+
const winner = diskHits[0];
|
|
5658
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
5659
|
+
const divergentSources = [
|
|
5660
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
5661
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
5662
|
+
];
|
|
5663
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.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;
|
|
5664
|
+
return sealCredential({
|
|
5665
|
+
apiKey: winner.value,
|
|
5666
|
+
tier: winner.src.tier,
|
|
5667
|
+
source: winner.src.path,
|
|
5668
|
+
deliberate: false,
|
|
5669
|
+
diskCandidates: diskPaths,
|
|
5670
|
+
warning
|
|
5671
|
+
});
|
|
5672
|
+
}
|
|
5673
|
+
if (envHit) {
|
|
5674
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
5675
|
+
return sealCredential({
|
|
5676
|
+
apiKey: envHit.value,
|
|
5677
|
+
tier: "env",
|
|
5678
|
+
source: envHit.key,
|
|
5679
|
+
deliberate: false,
|
|
5680
|
+
diskCandidates: diskPaths,
|
|
5681
|
+
warning: null
|
|
5682
|
+
});
|
|
5683
|
+
}
|
|
5684
|
+
return null;
|
|
5685
|
+
}
|
|
5686
|
+
var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
5687
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
5688
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
|
|
5689
|
+
var DEFAULT_AUTHORITY_SOURCE = "default";
|
|
5690
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
5691
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
5692
|
+
}
|
|
5693
|
+
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
5694
|
+
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
5695
|
+
function isValidDnsDomain(value) {
|
|
5696
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
5697
|
+
return false;
|
|
5698
|
+
}
|
|
5699
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
5700
|
+
}
|
|
5701
|
+
function validateAppSlug(name) {
|
|
5702
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
5703
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
5704
|
+
}
|
|
5705
|
+
return name;
|
|
5706
|
+
}
|
|
5707
|
+
function rawAuthority(value) {
|
|
5708
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
5709
|
+
if (!match)
|
|
5710
|
+
throw new Error("API URL must be absolute.");
|
|
5711
|
+
const afterScheme = value.slice(match[0].length);
|
|
5712
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
5713
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
5714
|
+
if (!authority)
|
|
5715
|
+
throw new Error("API URL must include a hostname.");
|
|
5716
|
+
return authority;
|
|
5717
|
+
}
|
|
5718
|
+
function assertCanonicalPort(port) {
|
|
5719
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
5720
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
5721
|
+
}
|
|
5722
|
+
const numericPort = Number(port);
|
|
5723
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
5724
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
5725
|
+
}
|
|
5726
|
+
}
|
|
5727
|
+
function canonicalAuthorityHostname(authority) {
|
|
5728
|
+
let rawHostname;
|
|
5729
|
+
if (authority.startsWith("[")) {
|
|
5730
|
+
const closingBracket = authority.indexOf("]");
|
|
5731
|
+
if (closingBracket === -1) {
|
|
5732
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
5733
|
+
}
|
|
5734
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
5735
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
5736
|
+
if (portSuffix) {
|
|
5737
|
+
if (!portSuffix.startsWith(":")) {
|
|
5738
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
5739
|
+
}
|
|
5740
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
5741
|
+
}
|
|
5742
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
5743
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
5744
|
+
}
|
|
5745
|
+
} else {
|
|
5746
|
+
const firstColon = authority.indexOf(":");
|
|
5747
|
+
const lastColon = authority.lastIndexOf(":");
|
|
5748
|
+
if (firstColon !== lastColon) {
|
|
5749
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
5750
|
+
}
|
|
5751
|
+
if (lastColon !== -1) {
|
|
5752
|
+
const port = authority.slice(lastColon + 1);
|
|
5753
|
+
assertCanonicalPort(port);
|
|
5754
|
+
rawHostname = authority.slice(0, lastColon);
|
|
5755
|
+
} else {
|
|
5756
|
+
rawHostname = authority;
|
|
5757
|
+
}
|
|
5758
|
+
const ipVersion = isIP(rawHostname);
|
|
5759
|
+
const numericAddressParts = rawHostname.split(".");
|
|
5760
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
5761
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
5762
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
5763
|
+
}
|
|
5764
|
+
}
|
|
5765
|
+
return rawHostname.toLowerCase();
|
|
5766
|
+
}
|
|
5767
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
5768
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
5769
|
+
}
|
|
5770
|
+
function toV1BaseUrl(apiUrl) {
|
|
5771
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
5772
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
5773
|
+
}
|
|
5774
|
+
const input = apiUrl.trim();
|
|
5775
|
+
const authority = rawAuthority(input);
|
|
5776
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
5777
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
5778
|
+
}
|
|
5779
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
5780
|
+
const url = new URL(input);
|
|
5781
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
5782
|
+
throw new Error("API URL must use http or https.");
|
|
5783
|
+
}
|
|
5784
|
+
if (url.username || url.password) {
|
|
5785
|
+
throw new Error("API URL must not include credentials.");
|
|
5786
|
+
}
|
|
5787
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
5788
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
5789
|
+
}
|
|
5790
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
5791
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
5792
|
+
}
|
|
5793
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
5794
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
5795
|
+
}
|
|
5796
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
5797
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
5798
|
+
}
|
|
5799
|
+
if (url.search || url.hash) {
|
|
5800
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
5801
|
+
}
|
|
5802
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
5803
|
+
if (path.endsWith("/v1"))
|
|
5804
|
+
path = path.slice(0, -"/v1".length);
|
|
5805
|
+
url.pathname = `${path}/v1`;
|
|
5806
|
+
return url.toString().replace(/\/+$/, "");
|
|
5807
|
+
}
|
|
5808
|
+
class ClientTransportConfigurationError extends Error {
|
|
5809
|
+
appName;
|
|
5810
|
+
sources;
|
|
5811
|
+
constructor(appName, message, sources = []) {
|
|
5812
|
+
super(message);
|
|
5813
|
+
this.name = "ClientTransportConfigurationError";
|
|
5814
|
+
this.appName = appName;
|
|
5815
|
+
this.sources = Object.freeze([...sources]);
|
|
5816
|
+
}
|
|
5817
|
+
}
|
|
5818
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
5819
|
+
env = snapshotClientEnvironment(name, env);
|
|
5820
|
+
const keys = clientTransportEnvKeys(name);
|
|
5821
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
5822
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
5823
|
+
if (blankUrl) {
|
|
5824
|
+
throw new ClientTransportConfigurationError(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
|
|
5825
|
+
}
|
|
5826
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
5827
|
+
if (controlledUrl) {
|
|
5828
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
5829
|
+
}
|
|
5830
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
5831
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
5832
|
+
throw new ClientTransportConfigurationError(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
5833
|
+
}
|
|
5834
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
5835
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
5836
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
5837
|
+
if (diskConfigUrlHit?.unusable) {
|
|
5838
|
+
throw new ClientTransportConfigurationError(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
|
|
5839
|
+
}
|
|
5840
|
+
const urlCandidates = [
|
|
5841
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
5842
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
5843
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
5844
|
+
];
|
|
5845
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
5846
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
5847
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
5848
|
+
throw new ClientTransportConfigurationError(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
|
|
5849
|
+
}
|
|
5850
|
+
const warnings = [];
|
|
5851
|
+
if (configuredUrl && !envUrlHit) {
|
|
5852
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
|
|
5853
|
+
}
|
|
5854
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
5855
|
+
if (!credential) {
|
|
5856
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
5857
|
+
const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
|
|
5858
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
5859
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
5860
|
+
}
|
|
5861
|
+
if (credential.warning)
|
|
5862
|
+
warnings.push(credential.warning);
|
|
5863
|
+
let urlHit;
|
|
5864
|
+
if (configuredUrl) {
|
|
5865
|
+
urlHit = configuredUrl;
|
|
5866
|
+
} else {
|
|
5867
|
+
try {
|
|
5868
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
5869
|
+
} catch (error) {
|
|
5870
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5871
|
+
throw new ClientTransportConfigurationError(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
|
|
5872
|
+
}
|
|
5873
|
+
}
|
|
5874
|
+
const apiUrlSource = urlHit.key;
|
|
5875
|
+
let baseUrl;
|
|
5876
|
+
try {
|
|
5877
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
5878
|
+
} catch (error) {
|
|
5879
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5880
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
5881
|
+
}
|
|
5882
|
+
return {
|
|
5883
|
+
resolution: {
|
|
5884
|
+
transport: "http",
|
|
5885
|
+
transportSource: urlHit.key,
|
|
5886
|
+
baseUrl,
|
|
5887
|
+
apiUrlSource,
|
|
5888
|
+
apiKeyPresent: true,
|
|
5889
|
+
apiKeySource: credential.source,
|
|
5890
|
+
apiKeyTier: credential.tier,
|
|
5891
|
+
misconfigured: false,
|
|
5892
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
5893
|
+
},
|
|
5894
|
+
credential
|
|
5895
|
+
};
|
|
5896
|
+
}
|
|
5897
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
5898
|
+
return resolveClientTransportSnapshot(name, env, options).resolution;
|
|
5899
|
+
}
|
|
5900
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
5901
|
+
const paths = credentialDiskSources(name, env);
|
|
5902
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
5903
|
+
}
|
|
5904
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
5905
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
5906
|
+
"host",
|
|
5907
|
+
":authority",
|
|
5908
|
+
"forwarded",
|
|
5909
|
+
"x-forwarded-host",
|
|
5910
|
+
"x-original-host"
|
|
5911
|
+
]);
|
|
5912
|
+
|
|
5913
|
+
// ../contracts/dist/client/storage.js
|
|
5914
|
+
import { isIP as isIP2 } from "net";
|
|
5915
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
5916
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync3 } from "fs";
|
|
5917
|
+
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|
|
5918
|
+
import { createRequire as createRequire2 } from "module";
|
|
5919
|
+
import { hostname as osHostname2 } from "os";
|
|
5920
|
+
import { isAbsolute as isAbsolute3, join as join4 } from "path";
|
|
5921
|
+
function envToken2(name) {
|
|
5922
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
5923
|
+
}
|
|
5924
|
+
function clientTransportEnvKeys2(name) {
|
|
5925
|
+
const envSegment = envToken2(name);
|
|
5926
|
+
return {
|
|
5927
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
5928
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
5929
|
+
};
|
|
5930
|
+
}
|
|
5931
|
+
function credentialOverrideEnvKey2(name) {
|
|
5932
|
+
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
5933
|
+
}
|
|
5934
|
+
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
5935
|
+
function credentialPointerEnvKey2(name) {
|
|
5936
|
+
return `HASNA_${envToken2(name)}_API_KEY_REF`;
|
|
5937
|
+
}
|
|
5938
|
+
|
|
5939
|
+
class CredentialResolutionError2 extends Error {
|
|
5940
|
+
appName;
|
|
5941
|
+
attempted;
|
|
5942
|
+
constructor(appName, message, attempted) {
|
|
5943
|
+
super(message);
|
|
5944
|
+
this.name = "CredentialResolutionError";
|
|
5945
|
+
this.appName = appName;
|
|
5946
|
+
this.attempted = attempted;
|
|
5947
|
+
}
|
|
5948
|
+
}
|
|
5949
|
+
|
|
5950
|
+
class CredentialFileUnsafeError2 extends Error {
|
|
5951
|
+
path;
|
|
5952
|
+
constructor(path, reason) {
|
|
5953
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
5954
|
+
this.name = "CredentialFileUnsafeError";
|
|
5955
|
+
this.path = path;
|
|
5956
|
+
}
|
|
5957
|
+
}
|
|
5958
|
+
var HASNA_HOME_ENV_KEY2 = "HASNA_HOME";
|
|
5959
|
+
var HASNA_CONFIG_HOME_ENV_KEY2 = "HASNA_CONFIG_HOME";
|
|
5960
|
+
var KEYCHAIN_STATION_ENV_KEY2 = "HASNA_STATION";
|
|
5961
|
+
var HASNA_HOME_DIR2 = ".hasna";
|
|
5962
|
+
var CONFIG_SUBDIR2 = "config";
|
|
5963
|
+
var CREDENTIALS_FILE2 = "credentials";
|
|
5964
|
+
var KEYCHAIN_SECURITY_BIN2 = "/usr/bin/security";
|
|
5965
|
+
var KEYCHAIN_SERVICE_PREFIX2 = "hasna.credentials";
|
|
5966
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS2 = 44;
|
|
5967
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS2 = 1e4;
|
|
5968
|
+
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
5969
|
+
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
5970
|
+
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
5971
|
+
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
5972
|
+
var VAULT_POINTER_SHAPE2 = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
5973
|
+
function homeDir2(env) {
|
|
5974
|
+
const home = env.HOME?.trim();
|
|
5975
|
+
return home ? home : null;
|
|
5976
|
+
}
|
|
5977
|
+
function absoluteOverride2(env, key) {
|
|
5978
|
+
const value = env[key]?.trim();
|
|
5979
|
+
return value && isAbsolute3(value) ? value : null;
|
|
5980
|
+
}
|
|
5981
|
+
function hasnaHomeDir2(env) {
|
|
5982
|
+
const override = absoluteOverride2(env, HASNA_HOME_ENV_KEY2);
|
|
5983
|
+
if (override)
|
|
5984
|
+
return override;
|
|
5985
|
+
const home = homeDir2(env);
|
|
5986
|
+
return home ? join4(home, HASNA_HOME_DIR2) : null;
|
|
5987
|
+
}
|
|
5988
|
+
function appConfigDir2(name, env) {
|
|
5989
|
+
const configRoot = absoluteOverride2(env, HASNA_CONFIG_HOME_ENV_KEY2);
|
|
5990
|
+
if (configRoot)
|
|
5991
|
+
return join4(configRoot, name);
|
|
5992
|
+
const root = hasnaHomeDir2(env);
|
|
5993
|
+
return root ? join4(root, name, CONFIG_SUBDIR2) : null;
|
|
5994
|
+
}
|
|
5995
|
+
function credentialDiskSourceList2(name, env, profile = null) {
|
|
5996
|
+
if (!SAFE_APP_SLUG2.test(name))
|
|
5997
|
+
return [];
|
|
5998
|
+
const directory = appConfigDir2(name, env);
|
|
5999
|
+
if (!directory)
|
|
6000
|
+
return [];
|
|
6001
|
+
const file = profile ? `${CREDENTIALS_FILE2}-${profile}` : CREDENTIALS_FILE2;
|
|
6002
|
+
return [{ path: join4(directory, file), tier: "disk" }];
|
|
6003
|
+
}
|
|
6004
|
+
function credentialDiskSources2(name, env) {
|
|
6005
|
+
return credentialDiskSourceList2(name, env, null).map((s) => s.path);
|
|
6006
|
+
}
|
|
6007
|
+
function profileDiskSources2(name, env, profile) {
|
|
6008
|
+
return credentialDiskSourceList2(name, env, profile).map((s) => s.path);
|
|
6009
|
+
}
|
|
6010
|
+
function parseEnvFile2(text) {
|
|
6011
|
+
const values = new Map;
|
|
6012
|
+
const unusable = new Set;
|
|
6013
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
6014
|
+
const line = rawLine.trim();
|
|
6015
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
6016
|
+
continue;
|
|
6017
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
6018
|
+
const equals = withoutExport.indexOf("=");
|
|
6019
|
+
if (equals <= 0)
|
|
6020
|
+
continue;
|
|
6021
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
6022
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
6023
|
+
continue;
|
|
6024
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
6025
|
+
const quote = value[0];
|
|
6026
|
+
if (quote === '"' || quote === "'") {
|
|
6027
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
6028
|
+
unusable.add(key);
|
|
6029
|
+
continue;
|
|
6030
|
+
}
|
|
6031
|
+
value = value.slice(1, -1);
|
|
6032
|
+
}
|
|
6033
|
+
if (value.trim().length === 0) {
|
|
6034
|
+
unusable.add(key);
|
|
6035
|
+
continue;
|
|
6036
|
+
}
|
|
6037
|
+
if (values.has(key) && values.get(key) !== value)
|
|
6038
|
+
unusable.add(key);
|
|
6039
|
+
values.set(key, value);
|
|
6040
|
+
}
|
|
6041
|
+
return { values, unusable };
|
|
6042
|
+
}
|
|
6043
|
+
function configFileModeAllowed2(mode) {
|
|
6044
|
+
const permissions = mode & 4095;
|
|
6045
|
+
return permissions === 256 || permissions === 384;
|
|
6046
|
+
}
|
|
6047
|
+
function configFileReadsCoherent2(before, after) {
|
|
6048
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
6049
|
+
}
|
|
6050
|
+
function readAppConfigFile2(path) {
|
|
6051
|
+
const unsafe = (reason) => {
|
|
6052
|
+
throw new CredentialFileUnsafeError2(path, reason);
|
|
6053
|
+
};
|
|
6054
|
+
let fd = -1;
|
|
6055
|
+
try {
|
|
6056
|
+
fd = openSync2(path, O_RDONLY2 | O_NOFOLLOW2 | O_NONBLOCK2);
|
|
6057
|
+
} catch (error) {
|
|
6058
|
+
const code = error.code;
|
|
6059
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
6060
|
+
return null;
|
|
6061
|
+
if (code === "ELOOP")
|
|
6062
|
+
unsafe("the path is a symlink");
|
|
6063
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
6064
|
+
}
|
|
6065
|
+
try {
|
|
6066
|
+
const before = fstatSync2(fd);
|
|
6067
|
+
if (!before.isFile())
|
|
6068
|
+
unsafe("the path is not a regular file");
|
|
6069
|
+
if (!configFileModeAllowed2(before.mode)) {
|
|
6070
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
6071
|
+
}
|
|
6072
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
6073
|
+
if (uid !== undefined && before.uid !== uid)
|
|
6074
|
+
unsafe("the file is not owned by the current user");
|
|
6075
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
6076
|
+
unsafe("the file exceeds the size limit");
|
|
6077
|
+
const bytes = readFileSync3(fd);
|
|
6078
|
+
const after = fstatSync2(fd);
|
|
6079
|
+
if (!configFileReadsCoherent2(before, after)) {
|
|
6080
|
+
unsafe("the file changed while being read");
|
|
6081
|
+
}
|
|
6082
|
+
return parseEnvFile2(bytes.toString("utf8"));
|
|
6083
|
+
} finally {
|
|
6084
|
+
if (fd !== -1)
|
|
6085
|
+
closeSync2(fd);
|
|
6086
|
+
}
|
|
6087
|
+
}
|
|
6088
|
+
function readCredentialFile2(path, apiKeyKeys) {
|
|
6089
|
+
const parsed = readAppConfigFile2(path);
|
|
6090
|
+
if (!parsed)
|
|
6091
|
+
return null;
|
|
6092
|
+
for (const key of apiKeyKeys) {
|
|
6093
|
+
if (parsed.unusable.has(key)) {
|
|
6094
|
+
throw new CredentialFileUnsafeError2(path, `${key} is declared but blank or malformed`);
|
|
6095
|
+
}
|
|
6096
|
+
}
|
|
6097
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
6098
|
+
if (new Set(values).size > 1) {
|
|
6099
|
+
throw new CredentialFileUnsafeError2(path, "credential aliases disagree");
|
|
6100
|
+
}
|
|
6101
|
+
return values[0] ?? null;
|
|
6102
|
+
}
|
|
6103
|
+
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
6104
|
+
function appConfigDiskValue2(name, env, keys) {
|
|
6105
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY2.test(key));
|
|
6106
|
+
if (wanted.length === 0)
|
|
6107
|
+
return null;
|
|
6108
|
+
for (const path of credentialDiskSources2(name, env)) {
|
|
6109
|
+
const parsed = readAppConfigFile2(path);
|
|
6110
|
+
if (!parsed)
|
|
6111
|
+
continue;
|
|
6112
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
6113
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
6114
|
+
}
|
|
6115
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
6116
|
+
if (new Set(values).size > 1)
|
|
6117
|
+
throw new CredentialFileUnsafeError2(path, "configuration aliases disagree");
|
|
6118
|
+
for (const key of wanted) {
|
|
6119
|
+
if (parsed.unusable.has(key))
|
|
6120
|
+
return { key, value: "", path, unusable: true };
|
|
6121
|
+
const value = parsed.values.get(key)?.trim();
|
|
6122
|
+
if (value)
|
|
6123
|
+
return { key, value, path };
|
|
6124
|
+
}
|
|
6125
|
+
}
|
|
6126
|
+
return null;
|
|
6127
|
+
}
|
|
6128
|
+
function assertUsableCredential2(appName, source, value) {
|
|
6129
|
+
if (VAULT_POINTER_SHAPE2.test(value)) {
|
|
6130
|
+
throw new CredentialResolutionError2(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey2(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
6131
|
+
}
|
|
6132
|
+
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
6133
|
+
return;
|
|
6134
|
+
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]);
|
|
6135
|
+
}
|
|
6136
|
+
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
6137
|
+
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
6138
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
6139
|
+
function sealCredential2(fields) {
|
|
6140
|
+
const { apiKey } = fields;
|
|
6141
|
+
const visible = {
|
|
6142
|
+
tier: fields.tier,
|
|
6143
|
+
source: fields.source,
|
|
6144
|
+
deliberate: fields.deliberate,
|
|
6145
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
6146
|
+
warning: fields.warning
|
|
6147
|
+
};
|
|
6148
|
+
const sealed = { ...visible };
|
|
6149
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
6150
|
+
value: apiKey,
|
|
6151
|
+
enumerable: false,
|
|
6152
|
+
writable: false,
|
|
6153
|
+
configurable: false
|
|
6154
|
+
});
|
|
6155
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
6156
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
6157
|
+
value: fields.pointerVaultKey,
|
|
6158
|
+
enumerable: false,
|
|
6159
|
+
writable: false,
|
|
6160
|
+
configurable: false
|
|
6161
|
+
});
|
|
6162
|
+
}
|
|
6163
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
6164
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
6165
|
+
enumerable: false,
|
|
6166
|
+
writable: false,
|
|
6167
|
+
configurable: false
|
|
6168
|
+
});
|
|
6169
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
|
|
6170
|
+
value: true,
|
|
6171
|
+
enumerable: false,
|
|
6172
|
+
writable: false,
|
|
6173
|
+
configurable: false
|
|
6174
|
+
});
|
|
6175
|
+
return Object.freeze(sealed);
|
|
6176
|
+
}
|
|
5336
6177
|
function isSealedCredential(credential) {
|
|
5337
|
-
return credential[
|
|
6178
|
+
return credential[CREDENTIAL_SEAL2] === true;
|
|
5338
6179
|
}
|
|
5339
6180
|
function explicitCredential(appName, apiKey) {
|
|
5340
6181
|
const source = "explicit apiKey option";
|
|
5341
|
-
|
|
5342
|
-
return
|
|
6182
|
+
assertUsableCredential2(appName, source, apiKey);
|
|
6183
|
+
return sealCredential2({
|
|
5343
6184
|
apiKey,
|
|
5344
6185
|
tier: "argument",
|
|
5345
6186
|
source,
|
|
5346
6187
|
deliberate: true,
|
|
5347
|
-
deprecated: false,
|
|
5348
6188
|
diskCandidates: [],
|
|
5349
6189
|
warning: null
|
|
5350
6190
|
});
|
|
5351
6191
|
}
|
|
5352
6192
|
function validateAndSealResolvedCredential(appName, credential) {
|
|
5353
6193
|
const apiKey = credential.apiKey;
|
|
5354
|
-
|
|
6194
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
5355
6195
|
if (!isSealedCredential(credential)) {
|
|
5356
|
-
return
|
|
6196
|
+
return sealCredential2({
|
|
5357
6197
|
apiKey,
|
|
5358
6198
|
tier: "argument",
|
|
5359
6199
|
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
5360
6200
|
deliberate: true,
|
|
5361
|
-
deprecated: false,
|
|
5362
6201
|
diskCandidates: [],
|
|
5363
6202
|
warning: null
|
|
5364
6203
|
});
|
|
5365
6204
|
}
|
|
5366
|
-
return
|
|
6205
|
+
return sealCredential2({
|
|
5367
6206
|
apiKey,
|
|
5368
6207
|
tier: credential.tier,
|
|
5369
6208
|
source: credential.source,
|
|
5370
6209
|
deliberate: credential.deliberate,
|
|
5371
|
-
deprecated: credential.deprecated,
|
|
5372
6210
|
diskCandidates: credential.diskCandidates,
|
|
5373
|
-
warning: credential.warning
|
|
6211
|
+
warning: credential.warning,
|
|
6212
|
+
...credential.pointerVaultKey !== undefined ? { pointerVaultKey: credential.pointerVaultKey } : {}
|
|
5374
6213
|
});
|
|
5375
6214
|
}
|
|
5376
|
-
function
|
|
6215
|
+
function firstEnvValue2(env, keys) {
|
|
5377
6216
|
for (const key of keys) {
|
|
6217
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
6218
|
+
continue;
|
|
5378
6219
|
const value = env[key]?.trim();
|
|
5379
6220
|
if (value)
|
|
5380
6221
|
return { key, value };
|
|
5381
6222
|
}
|
|
5382
|
-
return null;
|
|
5383
|
-
}
|
|
5384
|
-
var
|
|
5385
|
-
function
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
}
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
6223
|
+
return null;
|
|
6224
|
+
}
|
|
6225
|
+
var AMBIENT_ENVIRONMENT2 = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
6226
|
+
function isAmbientEnvironment2(env) {
|
|
6227
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT2] === true;
|
|
6228
|
+
}
|
|
6229
|
+
function defaultKeychainRunner2(argv) {
|
|
6230
|
+
const result = spawnSync3(KEYCHAIN_SECURITY_BIN2, [...argv], {
|
|
6231
|
+
encoding: "utf8",
|
|
6232
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
6233
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS2
|
|
6234
|
+
});
|
|
6235
|
+
return {
|
|
6236
|
+
status: result.status,
|
|
6237
|
+
stdout: result.stdout ?? "",
|
|
6238
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
6239
|
+
};
|
|
6240
|
+
}
|
|
6241
|
+
function keychainTierEnabled2(env, options) {
|
|
6242
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
6243
|
+
return false;
|
|
6244
|
+
if (options.enabled !== undefined)
|
|
6245
|
+
return options.enabled;
|
|
6246
|
+
return options.run !== undefined || isAmbientEnvironment2(env);
|
|
6247
|
+
}
|
|
6248
|
+
function keychainAccount2(env, options) {
|
|
6249
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY2]?.trim();
|
|
6250
|
+
if (station)
|
|
6251
|
+
return station;
|
|
6252
|
+
const host = (options.hostname ?? osHostname2)().split(".")[0]?.trim() ?? "";
|
|
6253
|
+
if (host)
|
|
6254
|
+
return host;
|
|
6255
|
+
const user = env.USER?.trim();
|
|
6256
|
+
return user || null;
|
|
6257
|
+
}
|
|
6258
|
+
function keychainFailureHint2(text) {
|
|
6259
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
6260
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
6261
|
+
return clean ? `: ${clean}` : "";
|
|
6262
|
+
}
|
|
6263
|
+
function readKeychainItem2(name, env, kind, options) {
|
|
6264
|
+
if (!SAFE_APP_SLUG2.test(name) || !keychainTierEnabled2(env, options))
|
|
6265
|
+
return null;
|
|
6266
|
+
const account = keychainAccount2(env, options);
|
|
6267
|
+
if (!account)
|
|
6268
|
+
return null;
|
|
6269
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX2}.${name}.${kind}`;
|
|
6270
|
+
const source = `keychain:${service}@${account}`;
|
|
6271
|
+
const run = options.run ?? defaultKeychainRunner2;
|
|
6272
|
+
let result;
|
|
6273
|
+
try {
|
|
6274
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
6275
|
+
} catch (error) {
|
|
6276
|
+
const reason = keychainFailureHint2(error instanceof Error ? error.message : String(error));
|
|
6277
|
+
throw new CredentialResolutionError2(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
6278
|
+
}
|
|
6279
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS2)
|
|
6280
|
+
return null;
|
|
6281
|
+
if (result.status !== 0) {
|
|
6282
|
+
throw new CredentialResolutionError2(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint2(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
6283
|
+
}
|
|
6284
|
+
const value = result.stdout.trim();
|
|
6285
|
+
if (!value) {
|
|
6286
|
+
throw new CredentialResolutionError2(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
6287
|
+
}
|
|
6288
|
+
return { value, source };
|
|
6289
|
+
}
|
|
6290
|
+
function keychainConfigValue2(name, env, options = {}) {
|
|
6291
|
+
return readKeychainItem2(name, env, "api-url", options);
|
|
6292
|
+
}
|
|
6293
|
+
function snapshotClientEnvironment2(name, env) {
|
|
6294
|
+
const keys = clientTransportEnvKeys2(name);
|
|
6295
|
+
const ambient = isAmbientEnvironment2(env);
|
|
6296
|
+
const snapshot = Object.create(null);
|
|
6297
|
+
for (const key of [
|
|
6298
|
+
...keys.apiUrlKeys,
|
|
6299
|
+
...keys.apiKeyKeys,
|
|
6300
|
+
credentialOverrideEnvKey2(name),
|
|
6301
|
+
credentialPointerEnvKey2(name),
|
|
6302
|
+
CREDENTIAL_PROFILE_ENV_KEY2,
|
|
6303
|
+
"HOME",
|
|
6304
|
+
HASNA_HOME_ENV_KEY2,
|
|
6305
|
+
HASNA_CONFIG_HOME_ENV_KEY2,
|
|
6306
|
+
KEYCHAIN_STATION_ENV_KEY2,
|
|
6307
|
+
"USER"
|
|
6308
|
+
]) {
|
|
6309
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
6310
|
+
if (!descriptor)
|
|
6311
|
+
continue;
|
|
6312
|
+
if (!("value" in descriptor)) {
|
|
6313
|
+
throw new CredentialResolutionError2(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
6314
|
+
}
|
|
6315
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
6316
|
+
throw new CredentialResolutionError2(name, `${key} must be a string data property.`, [key]);
|
|
6317
|
+
}
|
|
6318
|
+
snapshot[key] = descriptor.value;
|
|
6319
|
+
}
|
|
6320
|
+
if (ambient) {
|
|
6321
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT2, {
|
|
6322
|
+
value: true,
|
|
6323
|
+
enumerable: false,
|
|
6324
|
+
writable: false,
|
|
6325
|
+
configurable: false
|
|
6326
|
+
});
|
|
5398
6327
|
}
|
|
6328
|
+
return Object.freeze(snapshot);
|
|
5399
6329
|
}
|
|
5400
|
-
function
|
|
5401
|
-
|
|
5402
|
-
const
|
|
5403
|
-
const
|
|
5404
|
-
if (
|
|
5405
|
-
|
|
5406
|
-
|
|
6330
|
+
function resolveCredential2(name, env, options = {}) {
|
|
6331
|
+
env = snapshotClientEnvironment2(name, env);
|
|
6332
|
+
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
6333
|
+
const diskPaths = credentialDiskSources2(name, env);
|
|
6334
|
+
if (options.apiKey !== undefined) {
|
|
6335
|
+
const explicitKey = options.apiKey.trim();
|
|
6336
|
+
if (!explicitKey) {
|
|
6337
|
+
throw new CredentialResolutionError2(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
6338
|
+
}
|
|
6339
|
+
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
6340
|
+
return sealCredential2({
|
|
5407
6341
|
apiKey: explicitKey,
|
|
5408
6342
|
tier: "argument",
|
|
5409
6343
|
source: "explicit apiKey argument",
|
|
5410
6344
|
deliberate: true,
|
|
5411
|
-
deprecated: false,
|
|
5412
6345
|
diskCandidates: diskPaths,
|
|
5413
6346
|
warning: null
|
|
5414
6347
|
});
|
|
5415
6348
|
}
|
|
5416
|
-
const overrideKeyName =
|
|
5417
|
-
const overrideRaw = env[overrideKeyName];
|
|
6349
|
+
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
6350
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
5418
6351
|
if (overrideRaw !== undefined) {
|
|
5419
6352
|
const override = overrideRaw.trim();
|
|
5420
6353
|
if (!override) {
|
|
5421
|
-
throw new
|
|
6354
|
+
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]);
|
|
5422
6355
|
}
|
|
5423
|
-
|
|
5424
|
-
return
|
|
6356
|
+
assertUsableCredential2(name, overrideKeyName, override);
|
|
6357
|
+
return sealCredential2({
|
|
5425
6358
|
apiKey: override,
|
|
5426
6359
|
tier: "override",
|
|
5427
6360
|
source: overrideKeyName,
|
|
5428
6361
|
deliberate: true,
|
|
5429
|
-
deprecated: false,
|
|
5430
6362
|
diskCandidates: diskPaths,
|
|
5431
6363
|
warning: null
|
|
5432
6364
|
});
|
|
5433
6365
|
}
|
|
5434
|
-
const
|
|
6366
|
+
const pointerKeyName = credentialPointerEnvKey2(name);
|
|
6367
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
6368
|
+
if (pointerRaw !== undefined) {
|
|
6369
|
+
const pointer = pointerRaw.trim();
|
|
6370
|
+
if (!pointer) {
|
|
6371
|
+
throw new CredentialResolutionError2(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
6372
|
+
}
|
|
6373
|
+
if (!VAULT_POINTER_SHAPE2.test(pointer)) {
|
|
6374
|
+
throw new CredentialResolutionError2(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
6375
|
+
}
|
|
6376
|
+
return sealCredential2({
|
|
6377
|
+
apiKey: "",
|
|
6378
|
+
pointerVaultKey: pointer,
|
|
6379
|
+
tier: "pointer",
|
|
6380
|
+
source: pointerKeyName,
|
|
6381
|
+
deliberate: true,
|
|
6382
|
+
diskCandidates: diskPaths,
|
|
6383
|
+
warning: null
|
|
6384
|
+
});
|
|
6385
|
+
}
|
|
6386
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
6387
|
+
throw new CredentialResolutionError2(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
6388
|
+
}
|
|
6389
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY2) ? env[CREDENTIAL_PROFILE_ENV_KEY2] : undefined;
|
|
6390
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
6391
|
+
throw new CredentialResolutionError2(name, `${CREDENTIAL_PROFILE_ENV_KEY2} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY2]);
|
|
6392
|
+
}
|
|
6393
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
5435
6394
|
if (profile) {
|
|
5436
|
-
const profileSource = options.profile?.trim() ? "explicit profile argument" :
|
|
5437
|
-
if (!
|
|
5438
|
-
throw new
|
|
6395
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
6396
|
+
if (!SAFE_PROFILE2.test(profile)) {
|
|
6397
|
+
throw new CredentialResolutionError2(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
5439
6398
|
}
|
|
5440
|
-
const paths =
|
|
6399
|
+
const paths = profileDiskSources2(name, env, profile);
|
|
5441
6400
|
for (const path of paths) {
|
|
5442
|
-
const value =
|
|
6401
|
+
const value = readCredentialFile2(path, apiKeyKeys);
|
|
5443
6402
|
if (value) {
|
|
5444
|
-
|
|
5445
|
-
return
|
|
6403
|
+
assertUsableCredential2(name, path, value);
|
|
6404
|
+
return sealCredential2({
|
|
5446
6405
|
apiKey: value,
|
|
5447
6406
|
tier: "profile",
|
|
5448
6407
|
source: path,
|
|
5449
6408
|
deliberate: true,
|
|
5450
|
-
deprecated: false,
|
|
5451
6409
|
diskCandidates: paths,
|
|
5452
6410
|
warning: null
|
|
5453
6411
|
});
|
|
5454
6412
|
}
|
|
5455
6413
|
}
|
|
5456
|
-
throw new
|
|
6414
|
+
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);
|
|
6415
|
+
}
|
|
6416
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
6417
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
6418
|
+
if (blankEnv) {
|
|
6419
|
+
throw new CredentialResolutionError2(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
6420
|
+
}
|
|
6421
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
6422
|
+
throw new CredentialResolutionError2(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
6423
|
+
}
|
|
6424
|
+
const envHit = firstEnvValue2(env, apiKeyKeys);
|
|
6425
|
+
const keychainHit = readKeychainItem2(name, env, "api-key", options.keychain ?? {});
|
|
6426
|
+
if (keychainHit) {
|
|
6427
|
+
assertUsableCredential2(name, keychainHit.source, keychainHit.value);
|
|
6428
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain 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;
|
|
6429
|
+
return sealCredential2({
|
|
6430
|
+
apiKey: keychainHit.value,
|
|
6431
|
+
tier: "keychain",
|
|
6432
|
+
source: keychainHit.source,
|
|
6433
|
+
deliberate: false,
|
|
6434
|
+
diskCandidates: diskPaths,
|
|
6435
|
+
warning
|
|
6436
|
+
});
|
|
5457
6437
|
}
|
|
5458
|
-
const
|
|
6438
|
+
const diskSourceList = credentialDiskSourceList2(name, env, null);
|
|
6439
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile2(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
5459
6440
|
if (diskHits.length > 0) {
|
|
5460
6441
|
const winner = diskHits[0];
|
|
5461
|
-
|
|
6442
|
+
assertUsableCredential2(name, winner.src.path, winner.value);
|
|
5462
6443
|
const divergentSources = [
|
|
5463
|
-
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
5464
|
-
...
|
|
5465
|
-
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
5466
|
-
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
5467
|
-
})()
|
|
6444
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
6445
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
5468
6446
|
];
|
|
5469
|
-
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;
|
|
5470
|
-
return
|
|
6447
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.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;
|
|
6448
|
+
return sealCredential2({
|
|
5471
6449
|
apiKey: winner.value,
|
|
5472
|
-
tier:
|
|
5473
|
-
source: winner.path,
|
|
6450
|
+
tier: winner.src.tier,
|
|
6451
|
+
source: winner.src.path,
|
|
5474
6452
|
deliberate: false,
|
|
5475
|
-
deprecated: false,
|
|
5476
6453
|
diskCandidates: diskPaths,
|
|
5477
6454
|
warning
|
|
5478
6455
|
});
|
|
5479
6456
|
}
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
const notified = deprecationNotified();
|
|
5487
|
-
if (!notified.has(name)) {
|
|
5488
|
-
notified.add(name);
|
|
5489
|
-
sink(message);
|
|
5490
|
-
}
|
|
5491
|
-
return sealCredential({
|
|
5492
|
-
apiKey: legacy.value,
|
|
5493
|
-
tier: "legacy-env",
|
|
5494
|
-
source: legacy.key,
|
|
6457
|
+
if (envHit) {
|
|
6458
|
+
assertUsableCredential2(name, envHit.key, envHit.value);
|
|
6459
|
+
return sealCredential2({
|
|
6460
|
+
apiKey: envHit.value,
|
|
6461
|
+
tier: "env",
|
|
6462
|
+
source: envHit.key,
|
|
5495
6463
|
deliberate: false,
|
|
5496
|
-
deprecated: true,
|
|
5497
6464
|
diskCandidates: diskPaths,
|
|
5498
|
-
warning:
|
|
6465
|
+
warning: null
|
|
5499
6466
|
});
|
|
5500
6467
|
}
|
|
5501
6468
|
return null;
|
|
5502
6469
|
}
|
|
5503
|
-
var
|
|
5504
|
-
var
|
|
5505
|
-
function
|
|
5506
|
-
|
|
5507
|
-
|
|
6470
|
+
var SECRETS_PACKAGE_SPECIFIER2 = "@hasna/" + "secrets";
|
|
6471
|
+
var requireSecretsSdk2 = createRequire2(import.meta.url);
|
|
6472
|
+
async function completePointerCredential(name, pointerResolution, env = process.env) {
|
|
6473
|
+
const vaultKey = pointerResolution.pointerVaultKey;
|
|
6474
|
+
const pointerEnvKey = pointerResolution.source;
|
|
6475
|
+
if (!vaultKey) {
|
|
6476
|
+
throw new CredentialResolutionError2(name, `Pointer resolution from ${pointerEnvKey} carries no vault item key; this is a defect in the resolver.`, [pointerEnvKey]);
|
|
5508
6477
|
}
|
|
5509
|
-
|
|
6478
|
+
let secretsSdk;
|
|
6479
|
+
try {
|
|
6480
|
+
secretsSdk = requireSecretsSdk2(SECRETS_PACKAGE_SPECIFIER2);
|
|
6481
|
+
} catch {
|
|
6482
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the secrets SDK (@hasna/secrets) is not installed ` + `in this process. A vault pointer is TERMINAL: install @hasna/secrets to resolve it, or unset ${pointerEnvKey}.`, [pointerEnvKey]);
|
|
6483
|
+
}
|
|
6484
|
+
let client;
|
|
6485
|
+
try {
|
|
6486
|
+
client = secretsSdk.createSecretsClientFromEnv(env);
|
|
6487
|
+
} catch {
|
|
6488
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the secrets client could not be configured from this ` + `environment (the secrets service URL and key env are missing or invalid). A vault pointer is TERMINAL and ` + `never falls through to a literal or disk credential.`, [pointerEnvKey]);
|
|
6489
|
+
}
|
|
6490
|
+
let secret;
|
|
6491
|
+
try {
|
|
6492
|
+
secret = await client.getSecret({ key: vaultKey });
|
|
6493
|
+
} catch {
|
|
6494
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the vault could not be reached or the item is ` + `unavailable. A vault pointer is TERMINAL and never falls through to a literal or disk credential.`, [pointerEnvKey]);
|
|
6495
|
+
}
|
|
6496
|
+
const value = secret.value;
|
|
6497
|
+
if (!value) {
|
|
6498
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} resolved vault item '${vaultKey}', but it holds no value. A vault pointer is TERMINAL.`, [pointerEnvKey]);
|
|
6499
|
+
}
|
|
6500
|
+
assertUsableCredential2(name, `${pointerEnvKey} -> vault:${vaultKey}`, value);
|
|
6501
|
+
return sealCredential2({
|
|
6502
|
+
apiKey: value,
|
|
6503
|
+
tier: "pointer",
|
|
6504
|
+
source: `${pointerEnvKey} -> vault:${vaultKey}`,
|
|
6505
|
+
deliberate: true,
|
|
6506
|
+
diskCandidates: pointerResolution.diskCandidates,
|
|
6507
|
+
warning: null
|
|
6508
|
+
});
|
|
5510
6509
|
}
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
6510
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN2 = "https://api.hasna.com";
|
|
6511
|
+
var DEFAULT_AUTHORITY_SOURCE2 = "default";
|
|
6512
|
+
function defaultFleetGatewayBaseUrl2(name) {
|
|
6513
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN2}/${validateAppSlug2(name)}`;
|
|
6514
|
+
}
|
|
6515
|
+
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
6516
|
+
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
6517
|
+
function isValidDnsDomain2(value) {
|
|
6518
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN2.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
6519
|
+
return false;
|
|
5517
6520
|
}
|
|
5518
|
-
return
|
|
6521
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
5519
6522
|
}
|
|
5520
|
-
function
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
return key;
|
|
6523
|
+
function validateAppSlug2(name) {
|
|
6524
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN2.test(name)) {
|
|
6525
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
5524
6526
|
}
|
|
5525
|
-
return
|
|
6527
|
+
return name;
|
|
5526
6528
|
}
|
|
5527
|
-
function
|
|
6529
|
+
function rawAuthority2(value) {
|
|
5528
6530
|
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
5529
6531
|
if (!match)
|
|
5530
6532
|
throw new Error("API URL must be absolute.");
|
|
@@ -5535,7 +6537,7 @@ function rawAuthority(value) {
|
|
|
5535
6537
|
throw new Error("API URL must include a hostname.");
|
|
5536
6538
|
return authority;
|
|
5537
6539
|
}
|
|
5538
|
-
function
|
|
6540
|
+
function assertCanonicalPort2(port) {
|
|
5539
6541
|
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
5540
6542
|
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
5541
6543
|
}
|
|
@@ -5544,7 +6546,7 @@ function assertCanonicalPort(port) {
|
|
|
5544
6546
|
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
5545
6547
|
}
|
|
5546
6548
|
}
|
|
5547
|
-
function
|
|
6549
|
+
function canonicalAuthorityHostname2(authority) {
|
|
5548
6550
|
let rawHostname;
|
|
5549
6551
|
if (authority.startsWith("[")) {
|
|
5550
6552
|
const closingBracket = authority.indexOf("]");
|
|
@@ -5557,9 +6559,9 @@ function canonicalAuthorityHostname(authority) {
|
|
|
5557
6559
|
if (!portSuffix.startsWith(":")) {
|
|
5558
6560
|
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
5559
6561
|
}
|
|
5560
|
-
|
|
6562
|
+
assertCanonicalPort2(portSuffix.slice(1));
|
|
5561
6563
|
}
|
|
5562
|
-
if (
|
|
6564
|
+
if (isIP2(rawHostname.slice(1, -1)) !== 6) {
|
|
5563
6565
|
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
5564
6566
|
}
|
|
5565
6567
|
} else {
|
|
@@ -5570,33 +6572,33 @@ function canonicalAuthorityHostname(authority) {
|
|
|
5570
6572
|
}
|
|
5571
6573
|
if (lastColon !== -1) {
|
|
5572
6574
|
const port = authority.slice(lastColon + 1);
|
|
5573
|
-
|
|
6575
|
+
assertCanonicalPort2(port);
|
|
5574
6576
|
rawHostname = authority.slice(0, lastColon);
|
|
5575
6577
|
} else {
|
|
5576
6578
|
rawHostname = authority;
|
|
5577
6579
|
}
|
|
5578
|
-
const ipVersion =
|
|
6580
|
+
const ipVersion = isIP2(rawHostname);
|
|
5579
6581
|
const numericAddressParts = rawHostname.split(".");
|
|
5580
6582
|
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
5581
|
-
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !
|
|
6583
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain2(rawHostname.toLowerCase())) {
|
|
5582
6584
|
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
5583
6585
|
}
|
|
5584
6586
|
}
|
|
5585
6587
|
return rawHostname.toLowerCase();
|
|
5586
6588
|
}
|
|
5587
|
-
function
|
|
6589
|
+
function isDeliberateLoopbackHttpAuthority2(authority) {
|
|
5588
6590
|
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
5589
6591
|
}
|
|
5590
|
-
function
|
|
5591
|
-
if (
|
|
6592
|
+
function toV1BaseUrl2(apiUrl) {
|
|
6593
|
+
if (ASCII_CONTROL_PATTERN2.test(apiUrl)) {
|
|
5592
6594
|
throw new Error("API URL must not contain ASCII control characters.");
|
|
5593
6595
|
}
|
|
5594
6596
|
const input = apiUrl.trim();
|
|
5595
|
-
const authority =
|
|
6597
|
+
const authority = rawAuthority2(input);
|
|
5596
6598
|
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
5597
6599
|
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
5598
6600
|
}
|
|
5599
|
-
const canonicalHostname =
|
|
6601
|
+
const canonicalHostname = canonicalAuthorityHostname2(authority);
|
|
5600
6602
|
const url = new URL(input);
|
|
5601
6603
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
5602
6604
|
throw new Error("API URL must use http or https.");
|
|
@@ -5613,7 +6615,7 @@ function toV1BaseUrl(apiUrl) {
|
|
|
5613
6615
|
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
5614
6616
|
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
5615
6617
|
}
|
|
5616
|
-
if (url.protocol === "http:" && !
|
|
6618
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority2(authority)) {
|
|
5617
6619
|
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
5618
6620
|
}
|
|
5619
6621
|
if (url.search || url.hash) {
|
|
@@ -5625,106 +6627,104 @@ function toV1BaseUrl(apiUrl) {
|
|
|
5625
6627
|
url.pathname = `${path}/v1`;
|
|
5626
6628
|
return url.toString().replace(/\/+$/, "");
|
|
5627
6629
|
}
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
6630
|
+
class ClientTransportConfigurationError2 extends Error {
|
|
6631
|
+
appName;
|
|
6632
|
+
sources;
|
|
6633
|
+
constructor(appName, message, sources = []) {
|
|
6634
|
+
super(message);
|
|
6635
|
+
this.name = "ClientTransportConfigurationError";
|
|
6636
|
+
this.appName = appName;
|
|
6637
|
+
this.sources = Object.freeze([...sources]);
|
|
6638
|
+
}
|
|
6639
|
+
}
|
|
6640
|
+
function resolveClientTransportSnapshot2(name, env = process.env, options = {}) {
|
|
6641
|
+
env = snapshotClientEnvironment2(name, env);
|
|
6642
|
+
const keys = clientTransportEnvKeys2(name);
|
|
6643
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
6644
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
6645
|
+
if (blankUrl) {
|
|
6646
|
+
throw new ClientTransportConfigurationError2(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
|
|
6647
|
+
}
|
|
6648
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN2.test(entry.raw));
|
|
6649
|
+
if (controlledUrl) {
|
|
6650
|
+
throw new ClientTransportConfigurationError2(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
6651
|
+
}
|
|
6652
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
6653
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
6654
|
+
throw new ClientTransportConfigurationError2(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
6655
|
+
}
|
|
6656
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
6657
|
+
const keychainUrlHit = keychainConfigValue2(name, env, options.credentials?.keychain);
|
|
6658
|
+
const diskConfigUrlHit = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
6659
|
+
if (diskConfigUrlHit?.unusable) {
|
|
6660
|
+
throw new ClientTransportConfigurationError2(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
|
|
6661
|
+
}
|
|
6662
|
+
const urlCandidates = [
|
|
6663
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
6664
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
6665
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
6666
|
+
];
|
|
6667
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
6668
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
6669
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
6670
|
+
throw new ClientTransportConfigurationError2(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
|
|
5665
6671
|
}
|
|
5666
|
-
|
|
5667
|
-
|
|
6672
|
+
const warnings = [];
|
|
6673
|
+
if (configuredUrl && !envUrlHit) {
|
|
6674
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
|
|
5668
6675
|
}
|
|
5669
|
-
const credential =
|
|
6676
|
+
const credential = resolveCredential2(name, env, options.credentials);
|
|
5670
6677
|
if (!credential) {
|
|
5671
|
-
const diskHint =
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
transportSource: urlHit.key,
|
|
5676
|
-
baseUrl: null,
|
|
5677
|
-
apiUrlSource: urlHit.key,
|
|
5678
|
-
apiKeyPresent: false,
|
|
5679
|
-
apiKeySource: null,
|
|
5680
|
-
apiKeyTier: null,
|
|
5681
|
-
misconfigured: true,
|
|
5682
|
-
warning: warnings.join(" ")
|
|
5683
|
-
};
|
|
6678
|
+
const diskHint = credentialDiskSourcesForMessage2(name, env);
|
|
6679
|
+
const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
|
|
6680
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
6681
|
+
throw new ClientTransportConfigurationError2(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
5684
6682
|
}
|
|
5685
6683
|
if (credential.warning)
|
|
5686
6684
|
warnings.push(credential.warning);
|
|
6685
|
+
let urlHit;
|
|
6686
|
+
if (configuredUrl) {
|
|
6687
|
+
urlHit = configuredUrl;
|
|
6688
|
+
} else {
|
|
6689
|
+
try {
|
|
6690
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE2, value: defaultFleetGatewayBaseUrl2(name) };
|
|
6691
|
+
} catch (error) {
|
|
6692
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6693
|
+
throw new ClientTransportConfigurationError2(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
|
|
6694
|
+
}
|
|
6695
|
+
}
|
|
5687
6696
|
const apiUrlSource = urlHit.key;
|
|
5688
6697
|
let baseUrl;
|
|
5689
6698
|
try {
|
|
5690
|
-
baseUrl =
|
|
6699
|
+
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
5691
6700
|
} catch (error) {
|
|
5692
6701
|
const message = error instanceof Error ? error.message : String(error);
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
6702
|
+
throw new ClientTransportConfigurationError2(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
6703
|
+
}
|
|
6704
|
+
return {
|
|
6705
|
+
resolution: {
|
|
6706
|
+
transport: "http",
|
|
5696
6707
|
transportSource: urlHit.key,
|
|
5697
|
-
baseUrl
|
|
5698
|
-
apiUrlSource
|
|
6708
|
+
baseUrl,
|
|
6709
|
+
apiUrlSource,
|
|
5699
6710
|
apiKeyPresent: true,
|
|
5700
6711
|
apiKeySource: credential.source,
|
|
5701
6712
|
apiKeyTier: credential.tier,
|
|
5702
|
-
misconfigured:
|
|
5703
|
-
warning: warnings.join(" ")
|
|
5704
|
-
}
|
|
5705
|
-
|
|
5706
|
-
return {
|
|
5707
|
-
transport: "http",
|
|
5708
|
-
transportSource: urlHit.key,
|
|
5709
|
-
baseUrl,
|
|
5710
|
-
apiUrlSource,
|
|
5711
|
-
apiKeyPresent: true,
|
|
5712
|
-
apiKeySource: credential.source,
|
|
5713
|
-
apiKeyTier: credential.tier,
|
|
5714
|
-
misconfigured: false,
|
|
5715
|
-
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
6713
|
+
misconfigured: false,
|
|
6714
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
6715
|
+
},
|
|
6716
|
+
credential
|
|
5716
6717
|
};
|
|
5717
6718
|
}
|
|
5718
|
-
function
|
|
5719
|
-
const paths =
|
|
5720
|
-
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
6719
|
+
function credentialDiskSourcesForMessage2(name, env) {
|
|
6720
|
+
const paths = credentialDiskSources2(name, env);
|
|
6721
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
5721
6722
|
}
|
|
5722
6723
|
|
|
5723
6724
|
class HasnaHttpError extends Error {
|
|
5724
6725
|
status;
|
|
5725
6726
|
method;
|
|
5726
6727
|
path;
|
|
5727
|
-
body;
|
|
5728
6728
|
credentialSource;
|
|
5729
6729
|
credentialTier;
|
|
5730
6730
|
constructor(method, path, status, body, credential) {
|
|
@@ -5734,7 +6734,12 @@ class HasnaHttpError extends Error {
|
|
|
5734
6734
|
this.status = status;
|
|
5735
6735
|
this.method = method;
|
|
5736
6736
|
this.path = path;
|
|
5737
|
-
this
|
|
6737
|
+
Object.defineProperty(this, "body", {
|
|
6738
|
+
value: body,
|
|
6739
|
+
enumerable: status !== 401 && status !== 403,
|
|
6740
|
+
writable: false,
|
|
6741
|
+
configurable: false
|
|
6742
|
+
});
|
|
5738
6743
|
this.credentialSource = credential?.source ?? null;
|
|
5739
6744
|
this.credentialTier = credential?.tier ?? null;
|
|
5740
6745
|
}
|
|
@@ -5745,22 +6750,32 @@ function currentCredential(name, apiKey) {
|
|
|
5745
6750
|
}
|
|
5746
6751
|
return explicitCredential(name, apiKey);
|
|
5747
6752
|
}
|
|
6753
|
+
async function resolveRequestCredential(name, apiKey, env = process.env) {
|
|
6754
|
+
const resolved = currentCredential(name, apiKey);
|
|
6755
|
+
if (resolved.tier === "pointer") {
|
|
6756
|
+
return completePointerCredential(name, resolved, env);
|
|
6757
|
+
}
|
|
6758
|
+
return resolved;
|
|
6759
|
+
}
|
|
5748
6760
|
function authFailureGuidance(credential) {
|
|
5749
6761
|
const origin = `The API key for this request came from ${credential.source}`;
|
|
5750
6762
|
if (credential.deliberate) {
|
|
5751
6763
|
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.`;
|
|
5752
6764
|
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}`;
|
|
5753
6765
|
}
|
|
5754
|
-
if (credential.
|
|
6766
|
+
if (credential.tier === "env") {
|
|
5755
6767
|
const target = credential.diskCandidates[0];
|
|
5756
|
-
const remedy = target ? `
|
|
5757
|
-
return `${origin}, a variable in this process's environment
|
|
6768
|
+
const remedy = target ? `Store the CURRENT key in the Keychain or write it to ${target} \u2014 both are re-read on every call, so ` + `rotations take effect immediately and in every shell. Do not simply unset ${credential.source}: ` + `nothing was found in the Keychain or on disk, so that would leave this client with no credential at all.` : `This environment has no HOME or HASNA_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.`;
|
|
6769
|
+
return `${origin}, a variable in this process's environment. If a wrapper injected it for this one process, the ` + `wrapper re-reads its store on every invocation and the stored key itself is being rejected \u2014 rotate it. ` + `If this SHELL exported it, the export is a snapshot taken when the shell started: a STALE SHELL that ` + `exported the key before it was rotated keeps sending the old one until it exits. ${remedy}`;
|
|
6770
|
+
}
|
|
6771
|
+
if (credential.tier === "keychain") {
|
|
6772
|
+
return `${origin}, which was re-read from the Keychain on this very call \u2014 so a stale shell is NOT the cause ` + `here. The stored item is genuinely being rejected: update it with the current key, or re-run the fleet ` + `key distribution so this machine gets the current key.`;
|
|
5758
6773
|
}
|
|
5759
6774
|
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.`;
|
|
5760
6775
|
}
|
|
5761
6776
|
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
5762
|
-
var
|
|
5763
|
-
var
|
|
6777
|
+
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
6778
|
+
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
5764
6779
|
"host",
|
|
5765
6780
|
":authority",
|
|
5766
6781
|
"forwarded",
|
|
@@ -5770,7 +6785,7 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
|
5770
6785
|
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
5771
6786
|
if (!headers)
|
|
5772
6787
|
return;
|
|
5773
|
-
const forbidden = Object.keys(headers).find((name) =>
|
|
6788
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
5774
6789
|
if (forbidden) {
|
|
5775
6790
|
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
5776
6791
|
}
|
|
@@ -5797,9 +6812,9 @@ function appendQuery(path, query) {
|
|
|
5797
6812
|
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
5798
6813
|
}
|
|
5799
6814
|
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
5800
|
-
function
|
|
6815
|
+
function createHasnaHttpTransportInternal(options, requestBindingProvider) {
|
|
5801
6816
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
5802
|
-
const base =
|
|
6817
|
+
const base = toV1BaseUrl2(options.baseUrl);
|
|
5803
6818
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
5804
6819
|
const sleep = options.sleepImpl ?? defaultSleep;
|
|
5805
6820
|
const defaultRetry = options.retry;
|
|
@@ -5859,13 +6874,20 @@ function createHasnaHttpTransport(options) {
|
|
|
5859
6874
|
if (opts.signal)
|
|
5860
6875
|
opts.signal.removeEventListener("abort", onAbort);
|
|
5861
6876
|
}
|
|
5862
|
-
const
|
|
6877
|
+
const authenticationFailure = response.status === 401 || response.status === 403;
|
|
5863
6878
|
let parsed = undefined;
|
|
5864
|
-
if (
|
|
6879
|
+
if (authenticationFailure) {
|
|
5865
6880
|
try {
|
|
5866
|
-
|
|
5867
|
-
} catch {
|
|
5868
|
-
|
|
6881
|
+
await response.body?.cancel();
|
|
6882
|
+
} catch {}
|
|
6883
|
+
} else {
|
|
6884
|
+
const text = await response.text();
|
|
6885
|
+
if (text.length > 0) {
|
|
6886
|
+
try {
|
|
6887
|
+
parsed = JSON.parse(text);
|
|
6888
|
+
} catch {
|
|
6889
|
+
parsed = text;
|
|
6890
|
+
}
|
|
5869
6891
|
}
|
|
5870
6892
|
}
|
|
5871
6893
|
if (!response.ok) {
|
|
@@ -5876,11 +6898,11 @@ function createHasnaHttpTransport(options) {
|
|
|
5876
6898
|
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
5877
6899
|
};
|
|
5878
6900
|
}
|
|
5879
|
-
if (
|
|
6901
|
+
if (authenticationFailure) {
|
|
5880
6902
|
return {
|
|
5881
6903
|
ok: false,
|
|
5882
6904
|
retryable: false,
|
|
5883
|
-
error: new HasnaHttpError(method, rel, response.status,
|
|
6905
|
+
error: new HasnaHttpError(method, rel, response.status, undefined, {
|
|
5884
6906
|
source: credential.source,
|
|
5885
6907
|
tier: credential.tier,
|
|
5886
6908
|
guidance: authFailureGuidance(credential)
|
|
@@ -5896,11 +6918,15 @@ function createHasnaHttpTransport(options) {
|
|
|
5896
6918
|
async function request(method, path, body, opts = {}) {
|
|
5897
6919
|
const upper = method.toUpperCase();
|
|
5898
6920
|
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
5899
|
-
const url = `${base}${rel}`;
|
|
5900
6921
|
const retry = resolveRetry(opts.retry);
|
|
5901
|
-
const methodRetryable =
|
|
6922
|
+
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
5902
6923
|
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
5903
|
-
const
|
|
6924
|
+
const binding = requestBindingProvider ? await requestBindingProvider() : {
|
|
6925
|
+
baseUrl: base,
|
|
6926
|
+
credential: await resolveRequestCredential(options.name, options.apiKey)
|
|
6927
|
+
};
|
|
6928
|
+
const url = `${binding.baseUrl}${rel}`;
|
|
6929
|
+
const credential = binding.credential;
|
|
5904
6930
|
let last = null;
|
|
5905
6931
|
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
5906
6932
|
const result = await once(upper, rel, url, body, opts, credential);
|
|
@@ -5928,49 +6954,44 @@ function createHasnaHttpTransport(options) {
|
|
|
5928
6954
|
}
|
|
5929
6955
|
function createClientTransport(name, env = process.env, overrides) {
|
|
5930
6956
|
const credentialOptions = overrides?.credentials;
|
|
5931
|
-
const
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
if (
|
|
5941
|
-
throw new
|
|
5942
|
-
}
|
|
5943
|
-
|
|
6957
|
+
const snapshotOptions = { ...credentialOptions ? { credentials: credentialOptions } : {} };
|
|
6958
|
+
const resolution = resolveClientTransportSnapshot2(name, env, snapshotOptions).resolution;
|
|
6959
|
+
const sameBinding = (left, right) => left.resolution.baseUrl === right.resolution.baseUrl && left.credential.apiKey === right.credential.apiKey && left.credential.pointerVaultKey === right.credential.pointerVaultKey && left.credential.source === right.credential.source && left.credential.tier === right.credential.tier;
|
|
6960
|
+
const unstableConfiguration = () => new ClientTransportConfigurationError2(name, "The configured service authority or credential changed while a request was being prepared; no authenticated request was sent.");
|
|
6961
|
+
const requestBindingProvider = async () => {
|
|
6962
|
+
const first = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
6963
|
+
const reviewed = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
6964
|
+
if (!sameBinding(first, reviewed))
|
|
6965
|
+
throw unstableConfiguration();
|
|
6966
|
+
if (reviewed.resolution.baseUrl !== resolution.baseUrl) {
|
|
6967
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
6968
|
+
}
|
|
6969
|
+
const credential = await resolveRequestCredential(name, () => reviewed.credential, env);
|
|
6970
|
+
const immediatelyBeforeDispatch = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
6971
|
+
if (!sameBinding(reviewed, immediatelyBeforeDispatch))
|
|
6972
|
+
throw unstableConfiguration();
|
|
6973
|
+
if (immediatelyBeforeDispatch.resolution.baseUrl !== resolution.baseUrl) {
|
|
6974
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
6975
|
+
}
|
|
6976
|
+
return { baseUrl: immediatelyBeforeDispatch.resolution.baseUrl, credential };
|
|
5944
6977
|
};
|
|
5945
6978
|
return {
|
|
5946
6979
|
transport: "http",
|
|
5947
|
-
client:
|
|
6980
|
+
client: createHasnaHttpTransportInternal({
|
|
5948
6981
|
name,
|
|
5949
6982
|
baseUrl: resolution.baseUrl,
|
|
5950
|
-
apiKey:
|
|
6983
|
+
apiKey: () => {
|
|
6984
|
+
throw new Error("The authenticated request binding provider was not invoked.");
|
|
6985
|
+
},
|
|
5951
6986
|
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
5952
6987
|
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
5953
6988
|
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
5954
6989
|
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
5955
6990
|
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
5956
|
-
}),
|
|
6991
|
+
}, requestBindingProvider),
|
|
5957
6992
|
resolution
|
|
5958
6993
|
};
|
|
5959
6994
|
}
|
|
5960
|
-
|
|
5961
|
-
// ../contracts/dist/client/storage.js
|
|
5962
|
-
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
5963
|
-
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
5964
|
-
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
5965
|
-
var DEPRECATION_REGISTRY2 = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
5966
|
-
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
5967
|
-
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
5968
|
-
"host",
|
|
5969
|
-
":authority",
|
|
5970
|
-
"forwarded",
|
|
5971
|
-
"x-forwarded-host",
|
|
5972
|
-
"x-original-host"
|
|
5973
|
-
]);
|
|
5974
6995
|
function resourcePath(resource) {
|
|
5975
6996
|
const trimmed = resource.replace(/^\/+|\/+$/g, "");
|
|
5976
6997
|
if (!trimmed)
|
|
@@ -6070,145 +7091,336 @@ function createHasnaStorageClient(name, transport) {
|
|
|
6070
7091
|
}
|
|
6071
7092
|
};
|
|
6072
7093
|
}
|
|
7094
|
+
function resolveStorageClient(name, env = process.env, overrides) {
|
|
7095
|
+
const wired = createClientTransport(name, env, overrides);
|
|
7096
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
7097
|
+
}
|
|
7098
|
+
|
|
7099
|
+
// src/lib/local-opt-in.ts
|
|
7100
|
+
var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
|
|
7101
|
+
"HASNA_RECORDINGS_LOCAL",
|
|
7102
|
+
"RECORDINGS_LOCAL"
|
|
7103
|
+
];
|
|
7104
|
+
function isRecordingsLocalOptIn(env = process.env) {
|
|
7105
|
+
return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
7106
|
+
}
|
|
7107
|
+
var RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
|
|
7108
|
+
"RECORDINGS_API_URL",
|
|
7109
|
+
"RECORDINGS_API_KEY"
|
|
7110
|
+
];
|
|
7111
|
+
function recordingsAuthorityEnvKeys() {
|
|
7112
|
+
const keys = clientTransportEnvKeys("recordings");
|
|
7113
|
+
return [
|
|
7114
|
+
...keys.apiUrlKeys,
|
|
7115
|
+
...keys.apiKeyKeys,
|
|
7116
|
+
credentialOverrideEnvKey("recordings"),
|
|
7117
|
+
credentialPointerEnvKey("recordings"),
|
|
7118
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
7119
|
+
].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
|
|
7120
|
+
}
|
|
7121
|
+
function hasRecordingsEnvAuthorityIntent(env = process.env) {
|
|
7122
|
+
return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
7123
|
+
}
|
|
7124
|
+
function selectsRecordingsLocalStore(env = process.env) {
|
|
7125
|
+
return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
|
|
7126
|
+
}
|
|
7127
|
+
function recordingsResolverEnv(env) {
|
|
7128
|
+
const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
|
|
7129
|
+
const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
7130
|
+
const removed = new Set([...carved, ...blanks]);
|
|
7131
|
+
if (removed.size === 0)
|
|
7132
|
+
return env;
|
|
7133
|
+
const next = { ...env };
|
|
7134
|
+
for (const key of removed)
|
|
7135
|
+
delete next[key];
|
|
7136
|
+
return next;
|
|
7137
|
+
}
|
|
7138
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
7139
|
+
function isAmbientRecordingsEnv(env) {
|
|
7140
|
+
if (typeof process !== "undefined" && env === process.env) {
|
|
7141
|
+
return true;
|
|
7142
|
+
}
|
|
7143
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
7144
|
+
}
|
|
7145
|
+
function recordingsResolverInputs(env, credentials = {}) {
|
|
7146
|
+
const normalised = recordingsResolverEnv(env);
|
|
7147
|
+
if (normalised === env)
|
|
7148
|
+
return { env: normalised, credentials };
|
|
7149
|
+
const keychain = { ...credentials.keychain };
|
|
7150
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
7151
|
+
keychain.enabled = isAmbientRecordingsEnv(env);
|
|
7152
|
+
}
|
|
7153
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
7154
|
+
}
|
|
6073
7155
|
|
|
6074
7156
|
// src/http/client.ts
|
|
6075
|
-
|
|
6076
|
-
|
|
7157
|
+
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
7158
|
+
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
7159
|
+
function throwRecordingsAuthorityFailure(error) {
|
|
7160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7161
|
+
const name = error instanceof Error ? error.name : "";
|
|
7162
|
+
if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
|
|
7163
|
+
throw new Error(`REMOTE_API_CREDENTIAL_INVALID: ${message} There is no local fallback: ` + "local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
|
|
7164
|
+
}
|
|
7165
|
+
if (/no API key could be resolved/.test(message)) {
|
|
7166
|
+
if (/is not set and no API key could be resolved/.test(message)) {
|
|
7167
|
+
throw new Error("REMOTE_API_CONFIG_MISSING: no Recordings credential resolved from the Keychain item " + `hasna.credentials.recordings.api-key, ~/.hasna/recordings/config/credentials, or HASNA_RECORDINGS_API_KEY. ${message} ` + "There is no local fallback: local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1, alias RECORDINGS_LOCAL=1) " + "and is disabled by default \u2014 failing closed instead of serving the local store", { cause: error });
|
|
7168
|
+
}
|
|
7169
|
+
throw new Error("REMOTE_API_KEY_MISSING: remote Recordings storage requires HASNA_RECORDINGS_API_KEY, the Keychain item " + `hasna.credentials.recordings.api-key, or ~/.hasna/recordings/config/credentials. ${message} ` + "There is no local fallback: local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
|
|
7170
|
+
}
|
|
7171
|
+
throw new Error(`REMOTE_API_URL_INVALID: ${message} local SQLite fallback is disabled`, { cause: error });
|
|
6077
7172
|
}
|
|
6078
|
-
function
|
|
6079
|
-
const token = envToken2(name);
|
|
7173
|
+
function toRecordsAuthority(resolution) {
|
|
6080
7174
|
return {
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
7175
|
+
transport: resolution.transport,
|
|
7176
|
+
transportSource: resolution.transportSource,
|
|
7177
|
+
baseUrl: resolution.baseUrl,
|
|
7178
|
+
apiUrlSource: resolution.apiUrlSource,
|
|
7179
|
+
apiKeyPresent: resolution.apiKeyPresent,
|
|
7180
|
+
apiKeySource: resolution.apiKeySource,
|
|
7181
|
+
apiKeyTier: resolution.apiKeyTier,
|
|
7182
|
+
misconfigured: resolution.misconfigured,
|
|
7183
|
+
warning: resolution.warning
|
|
6084
7184
|
};
|
|
6085
7185
|
}
|
|
6086
|
-
function
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
return "sqlite";
|
|
6090
|
-
if (normalized === "http" || normalized === "https")
|
|
6091
|
-
return "http";
|
|
6092
|
-
throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
|
|
6093
|
-
}
|
|
6094
|
-
function firstEnv2(env, keys) {
|
|
6095
|
-
for (const key of keys) {
|
|
6096
|
-
const value = env[key]?.trim();
|
|
6097
|
-
if (value)
|
|
6098
|
-
return { key, value };
|
|
7186
|
+
function resolveRecordingsTransport(env = process.env, options = {}) {
|
|
7187
|
+
if (selectsRecordingsLocalStore(env)) {
|
|
7188
|
+
return { transport: "sqlite", selected: false, source: "local-opt-in", authority: null };
|
|
6099
7189
|
}
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
7190
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
7191
|
+
let resolution;
|
|
7192
|
+
try {
|
|
7193
|
+
resolution = resolveClientTransport("recordings", resolverInputs.env, {
|
|
7194
|
+
credentials: resolverInputs.credentials
|
|
7195
|
+
});
|
|
7196
|
+
} catch (error) {
|
|
7197
|
+
throwRecordingsAuthorityFailure(error);
|
|
6106
7198
|
}
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
return url.toString().replace(/\/+$/, "");
|
|
7199
|
+
return {
|
|
7200
|
+
transport: "http",
|
|
7201
|
+
selected: true,
|
|
7202
|
+
source: `${resolution.apiKeySource ?? resolution.apiKeyTier}+${resolution.apiUrlSource ?? "default"}`,
|
|
7203
|
+
authority: toRecordsAuthority(resolution)
|
|
7204
|
+
};
|
|
6114
7205
|
}
|
|
6115
|
-
function
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
requested = normalizeClientStore(storeHit.value);
|
|
6124
|
-
modeSource = storeHit.key;
|
|
6125
|
-
} else if (urlHit && keyHit) {
|
|
6126
|
-
requested = "http";
|
|
6127
|
-
modeSource = "auto:api-url+api-key";
|
|
6128
|
-
} else if (urlHit || keyHit) {
|
|
6129
|
-
const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
|
|
6130
|
-
const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
|
|
7206
|
+
function getRecordingsTransportStatus(env = process.env, options = {}) {
|
|
7207
|
+
let resolution;
|
|
7208
|
+
try {
|
|
7209
|
+
resolution = resolveRecordingsTransport(env, options);
|
|
7210
|
+
} catch (error) {
|
|
7211
|
+
const issue = error instanceof Error ? error.message : String(error);
|
|
7212
|
+
const envKeys = clientTransportEnvKeys("recordings");
|
|
7213
|
+
const declared = (keys) => keys.some((key) => (env[key] ?? "").trim() !== "");
|
|
6131
7214
|
return {
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
7215
|
+
selected: true,
|
|
7216
|
+
ok: false,
|
|
7217
|
+
transport: "invalid",
|
|
7218
|
+
api_url_configured: declared(envKeys.apiUrlKeys),
|
|
7219
|
+
api_key_configured: declared(envKeys.apiKeyKeys),
|
|
7220
|
+
api_url_source: null,
|
|
7221
|
+
api_key_source: null,
|
|
7222
|
+
api_key_tier: null,
|
|
7223
|
+
v1_base_url: null,
|
|
7224
|
+
issues: [issue],
|
|
7225
|
+
local_fallback: false
|
|
6139
7226
|
};
|
|
6140
7227
|
}
|
|
6141
|
-
if (
|
|
6142
|
-
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
6143
|
-
}
|
|
6144
|
-
if (!urlHit) {
|
|
7228
|
+
if (!resolution.selected) {
|
|
6145
7229
|
return {
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
7230
|
+
selected: false,
|
|
7231
|
+
ok: true,
|
|
7232
|
+
transport: resolution.transport,
|
|
7233
|
+
api_url_configured: false,
|
|
7234
|
+
api_key_configured: false,
|
|
7235
|
+
api_url_source: null,
|
|
7236
|
+
api_key_source: null,
|
|
7237
|
+
api_key_tier: null,
|
|
7238
|
+
v1_base_url: null,
|
|
7239
|
+
issues: [],
|
|
7240
|
+
local_fallback: false
|
|
6153
7241
|
};
|
|
6154
7242
|
}
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
7243
|
+
const authority = resolution.authority;
|
|
7244
|
+
return {
|
|
7245
|
+
selected: true,
|
|
7246
|
+
ok: true,
|
|
7247
|
+
transport: resolution.transport,
|
|
7248
|
+
api_url_configured: authority.apiUrlSource !== null && authority.apiUrlSource !== "default",
|
|
7249
|
+
api_key_configured: authority.apiKeyPresent,
|
|
7250
|
+
api_url_source: authority.apiUrlSource,
|
|
7251
|
+
api_key_source: authority.apiKeySource,
|
|
7252
|
+
api_key_tier: authority.apiKeyTier,
|
|
7253
|
+
v1_base_url: authority.baseUrl,
|
|
7254
|
+
issues: [],
|
|
7255
|
+
local_fallback: false
|
|
7256
|
+
};
|
|
7257
|
+
}
|
|
7258
|
+
function resolveRecordingsCloudClient(env = process.env, options = {}) {
|
|
7259
|
+
if (selectsRecordingsLocalStore(env))
|
|
7260
|
+
return null;
|
|
7261
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
7262
|
+
let resolved;
|
|
7263
|
+
try {
|
|
7264
|
+
resolved = resolveStorageClient("recordings", resolverInputs.env, {
|
|
7265
|
+
fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
|
|
7266
|
+
credentials: resolverInputs.credentials
|
|
7267
|
+
});
|
|
7268
|
+
} catch (error) {
|
|
7269
|
+
throwRecordingsAuthorityFailure(error);
|
|
6165
7270
|
}
|
|
6166
|
-
const
|
|
6167
|
-
|
|
7271
|
+
const client = resolved.client;
|
|
7272
|
+
return client;
|
|
7273
|
+
}
|
|
7274
|
+
|
|
7275
|
+
// src/store.ts
|
|
7276
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
7277
|
+
|
|
7278
|
+
// src/lib/audio-artifact-storage.ts
|
|
7279
|
+
import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
|
|
7280
|
+
import { createHash } from "crypto";
|
|
7281
|
+
import { readFile } from "fs/promises";
|
|
7282
|
+
import { extname } from "path";
|
|
7283
|
+
var SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
7284
|
+
var DEFAULT_S3_PREFIX = "recordings";
|
|
7285
|
+
var AUDIO_CONTENT_TYPES = {
|
|
7286
|
+
wav: "audio/wav",
|
|
7287
|
+
mp3: "audio/mpeg",
|
|
7288
|
+
m4a: "audio/mp4",
|
|
7289
|
+
aac: "audio/aac",
|
|
7290
|
+
webm: "audio/webm",
|
|
7291
|
+
ogg: "audio/ogg",
|
|
7292
|
+
flac: "audio/flac"
|
|
7293
|
+
};
|
|
7294
|
+
function contentTypeForExtension(extension) {
|
|
7295
|
+
return AUDIO_CONTENT_TYPES[extension.toLowerCase()] ?? "application/octet-stream";
|
|
7296
|
+
}
|
|
7297
|
+
|
|
7298
|
+
class AudioArtifactStorage {
|
|
7299
|
+
bucket;
|
|
7300
|
+
prefix;
|
|
7301
|
+
s3;
|
|
7302
|
+
constructor(options = {}) {
|
|
7303
|
+
this.bucket = options.bucket;
|
|
7304
|
+
this.prefix = (options.prefix || DEFAULT_S3_PREFIX).replace(/^\/+|\/+$/g, "");
|
|
7305
|
+
this.s3 = this.bucket ? options.client ?? new AwsS3Client({ region: options.region || process.env.AWS_REGION || "us-east-1" }) : undefined;
|
|
7306
|
+
}
|
|
7307
|
+
get usesS3() {
|
|
7308
|
+
return Boolean(this.bucket);
|
|
7309
|
+
}
|
|
7310
|
+
objectKeyFor(recordingId, sha256, extension) {
|
|
7311
|
+
assertSha256(sha256);
|
|
7312
|
+
const safeExtension = extension.toLowerCase().match(/^[a-z0-9]+/)?.[0] || "bin";
|
|
7313
|
+
return `${this.prefix}/${recordingId}/${sha256}.${safeExtension}`;
|
|
7314
|
+
}
|
|
7315
|
+
async uploadAudio(recordingId, audioPath) {
|
|
7316
|
+
if (!this.bucket || !this.s3)
|
|
7317
|
+
return null;
|
|
7318
|
+
const bytes = await readFileBytes(audioPath);
|
|
7319
|
+
if (!bytes)
|
|
7320
|
+
return null;
|
|
7321
|
+
const sha256 = sha256Hex(bytes);
|
|
7322
|
+
const extension = extensionOf(audioPath);
|
|
7323
|
+
const key = this.objectKeyFor(recordingId, sha256, extension);
|
|
7324
|
+
await this.s3.send(new PutObjectCommand({
|
|
7325
|
+
Bucket: this.bucket,
|
|
7326
|
+
Key: key,
|
|
7327
|
+
Body: bytes,
|
|
7328
|
+
ContentType: contentTypeForExtension(extension)
|
|
7329
|
+
}));
|
|
7330
|
+
return { objectKey: key, sha256, bytes: bytes.byteLength };
|
|
7331
|
+
}
|
|
7332
|
+
async readAudio(objectKey) {
|
|
7333
|
+
if (!this.bucket || !this.s3)
|
|
7334
|
+
return null;
|
|
7335
|
+
try {
|
|
7336
|
+
const response = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
|
7337
|
+
return await bodyToBytes(response.Body);
|
|
7338
|
+
} catch {
|
|
7339
|
+
return null;
|
|
7340
|
+
}
|
|
7341
|
+
}
|
|
7342
|
+
}
|
|
7343
|
+
function resolveAudioArtifactStorage(env = process.env, client) {
|
|
7344
|
+
const bucket = env.HASNA_RECORDINGS_S3_BUCKET || env.RECORDINGS_S3_BUCKET || undefined;
|
|
7345
|
+
const prefix = env.HASNA_RECORDINGS_S3_PREFIX || env.RECORDINGS_S3_PREFIX || undefined;
|
|
7346
|
+
const region = env.RECORDINGS_S3_REGION || env.AWS_REGION || undefined;
|
|
7347
|
+
return new AudioArtifactStorage({ bucket, prefix, region, client });
|
|
7348
|
+
}
|
|
7349
|
+
async function uploadAudioAtCreation(recordingId, audioPath, storage = resolveAudioArtifactStorage()) {
|
|
7350
|
+
if (!audioPath)
|
|
7351
|
+
return null;
|
|
7352
|
+
if (!storage.usesS3)
|
|
7353
|
+
return null;
|
|
6168
7354
|
try {
|
|
6169
|
-
|
|
7355
|
+
const uploaded = await storage.uploadAudio(recordingId, audioPath);
|
|
7356
|
+
if (!uploaded)
|
|
7357
|
+
return null;
|
|
7358
|
+
console.warn(`recordings: uploaded audio for ${recordingId} to ${uploaded.objectKey}`);
|
|
7359
|
+
return uploaded;
|
|
6170
7360
|
} catch (error) {
|
|
6171
|
-
|
|
6172
|
-
return
|
|
7361
|
+
console.warn(`recordings: audio upload failed for ${recordingId} (${String(error instanceof Error ? error.message : error)}); keeping local audio_path only`);
|
|
7362
|
+
return null;
|
|
6173
7363
|
}
|
|
6174
|
-
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
6175
7364
|
}
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
7365
|
+
function assertSha256(value) {
|
|
7366
|
+
if (!SHA256_HEX.test(value)) {
|
|
7367
|
+
throw new Error("audio digest must be a lowercase hex sha-256; refusing to build a storage key from it");
|
|
7368
|
+
}
|
|
7369
|
+
}
|
|
7370
|
+
function sha256Hex(bytes) {
|
|
7371
|
+
const hash = createHash("sha256");
|
|
7372
|
+
hash.update(bytes);
|
|
7373
|
+
return hash.digest("hex");
|
|
7374
|
+
}
|
|
7375
|
+
async function readFileBytes(path) {
|
|
7376
|
+
try {
|
|
7377
|
+
return new Uint8Array(await readFile(path));
|
|
7378
|
+
} catch {
|
|
7379
|
+
return null;
|
|
7380
|
+
}
|
|
7381
|
+
}
|
|
7382
|
+
function extensionOf(path) {
|
|
7383
|
+
const extension = extname(path).replace(/^\./, "").toLowerCase();
|
|
7384
|
+
return extension || "bin";
|
|
7385
|
+
}
|
|
7386
|
+
async function bodyToBytes(body) {
|
|
7387
|
+
if (!body)
|
|
7388
|
+
return new Uint8Array(0);
|
|
7389
|
+
if (body instanceof Uint8Array)
|
|
7390
|
+
return body;
|
|
7391
|
+
if (typeof body.transformToByteArray === "function") {
|
|
7392
|
+
return new Uint8Array(await body.transformToByteArray());
|
|
7393
|
+
}
|
|
7394
|
+
if (typeof body.getReader === "function") {
|
|
7395
|
+
const reader = body.getReader();
|
|
7396
|
+
const chunks2 = [];
|
|
7397
|
+
for (;; ) {
|
|
7398
|
+
const { value, done } = await reader.read();
|
|
7399
|
+
if (done)
|
|
7400
|
+
break;
|
|
7401
|
+
if (value)
|
|
7402
|
+
chunks2.push(value);
|
|
6196
7403
|
}
|
|
6197
|
-
|
|
7404
|
+
return concat(chunks2);
|
|
6198
7405
|
}
|
|
6199
|
-
|
|
6200
|
-
|
|
7406
|
+
const chunks = [];
|
|
7407
|
+
for await (const chunk of body) {
|
|
7408
|
+
chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
6201
7409
|
}
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
7410
|
+
return concat(chunks);
|
|
7411
|
+
}
|
|
7412
|
+
function concat(chunks) {
|
|
7413
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
7414
|
+
const merged = new Uint8Array(new ArrayBuffer(total));
|
|
7415
|
+
let offset = 0;
|
|
7416
|
+
for (const chunk of chunks) {
|
|
7417
|
+
merged.set(chunk, offset);
|
|
7418
|
+
offset += chunk.byteLength;
|
|
6205
7419
|
}
|
|
6206
|
-
return
|
|
7420
|
+
return merged;
|
|
6207
7421
|
}
|
|
6208
7422
|
|
|
6209
7423
|
// src/store.ts
|
|
6210
|
-
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
6211
|
-
var APP = "recordings";
|
|
6212
7424
|
function listQuery(filter) {
|
|
6213
7425
|
if (!filter)
|
|
6214
7426
|
return {};
|
|
@@ -6238,7 +7450,15 @@ var localStore = {
|
|
|
6238
7450
|
mode: "sqlite",
|
|
6239
7451
|
baseUrl: null,
|
|
6240
7452
|
async createRecording(input, idempotencyKey) {
|
|
6241
|
-
return withLocalStoreReaderLease(() =>
|
|
7453
|
+
return withLocalStoreReaderLease(async () => {
|
|
7454
|
+
const recording = createRecording(input, undefined, idempotencyKey);
|
|
7455
|
+
if (!recording.audio_path)
|
|
7456
|
+
return recording;
|
|
7457
|
+
const uploaded = await uploadAudioAtCreation(recording.id, recording.audio_path, localArtifactStorageFor());
|
|
7458
|
+
if (!uploaded)
|
|
7459
|
+
return recording;
|
|
7460
|
+
return updateRecordingAudio(recording.id, uploaded.objectKey, uploaded.sha256, uploaded.bytes) ?? recording;
|
|
7461
|
+
});
|
|
6242
7462
|
},
|
|
6243
7463
|
async getRecording(id) {
|
|
6244
7464
|
return withLocalStoreReaderLease(() => getRecording(id));
|
|
@@ -6432,7 +7652,7 @@ function apiStore(client) {
|
|
|
6432
7652
|
};
|
|
6433
7653
|
}
|
|
6434
7654
|
function recordingPageFingerprint(items) {
|
|
6435
|
-
const hash =
|
|
7655
|
+
const hash = createHash2("sha256");
|
|
6436
7656
|
const ids = items.map((item) => String(item.id ?? "")).sort();
|
|
6437
7657
|
for (const id of ids) {
|
|
6438
7658
|
hash.update(String(id.length));
|
|
@@ -6468,12 +7688,16 @@ async function countStoreRecordings(store, filter) {
|
|
|
6468
7688
|
throw new Error(`Legacy Store exceeded ${maxPageRequests} pages while counting recordings`);
|
|
6469
7689
|
}
|
|
6470
7690
|
var cached = null;
|
|
6471
|
-
|
|
6472
|
-
|
|
7691
|
+
var localArtifactStorageOverride;
|
|
7692
|
+
function localArtifactStorageFor() {
|
|
7693
|
+
return localArtifactStorageOverride ?? resolveAudioArtifactStorage();
|
|
7694
|
+
}
|
|
7695
|
+
function getStore(env = process.env, options = {}) {
|
|
7696
|
+
if (env === process.env && cached && options.credentials === undefined)
|
|
6473
7697
|
return cached;
|
|
6474
|
-
const
|
|
6475
|
-
const store =
|
|
6476
|
-
if (env === process.env)
|
|
7698
|
+
const client = resolveRecordingsCloudClient(env, options);
|
|
7699
|
+
const store = client ? apiStore(client) : localStore;
|
|
7700
|
+
if (env === process.env && options.credentials === undefined)
|
|
6477
7701
|
cached = store;
|
|
6478
7702
|
return store;
|
|
6479
7703
|
}
|
|
@@ -6502,7 +7726,7 @@ var _client = null;
|
|
|
6502
7726
|
var _clientApiKey = null;
|
|
6503
7727
|
function getClient(config) {
|
|
6504
7728
|
if (!config.openai_api_key) {
|
|
6505
|
-
throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var
|
|
7729
|
+
throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var (e.g. " + "'secrets exec <key> --as OPENAI_API_KEY -- recordings \u2026')");
|
|
6506
7730
|
}
|
|
6507
7731
|
if (_client && _clientApiKey === config.openai_api_key)
|
|
6508
7732
|
return _client;
|
|
@@ -6731,7 +7955,6 @@ function currentMachineId(env = process.env, hostName = hostname()) {
|
|
|
6731
7955
|
|
|
6732
7956
|
// src/mcp/index.ts
|
|
6733
7957
|
var config = loadConfig();
|
|
6734
|
-
ensureDataDir(config);
|
|
6735
7958
|
function runtimeConfig() {
|
|
6736
7959
|
return {
|
|
6737
7960
|
...loadConfig(),
|
|
@@ -7419,6 +8642,16 @@ async function main() {
|
|
|
7419
8642
|
printHelp();
|
|
7420
8643
|
return;
|
|
7421
8644
|
}
|
|
8645
|
+
const transportStatus = getRecordingsTransportStatus();
|
|
8646
|
+
if (!transportStatus.ok) {
|
|
8647
|
+
const issue = transportStatus.issues[0] ?? "REMOTE_API_CONFIG_MISSING";
|
|
8648
|
+
console.error(`ERROR: ${issue}`);
|
|
8649
|
+
process.exit(1);
|
|
8650
|
+
}
|
|
8651
|
+
if (selectsRecordingsLocalStore(process.env)) {
|
|
8652
|
+
console.error("recordings: LOCAL mode \u2014 HASNA_RECORDINGS_LOCAL is set and nothing configures an authority; " + "reading and writing the on-box SQLite store, not the hosted fleet. " + "Set HASNA_RECORDINGS_API_KEY, add the Keychain item hasna.credentials.recordings.api-key, " + "or write ~/.hasna/recordings/config/credentials to go hosted.");
|
|
8653
|
+
ensureDataDir(config);
|
|
8654
|
+
}
|
|
7422
8655
|
if (isStdioMode(args)) {
|
|
7423
8656
|
const transport = new StdioServerTransport;
|
|
7424
8657
|
await buildServer().connect(transport);
|