@hasna/recordings 0.3.13 → 0.5.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 +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- 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/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- 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 +2879 -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 +1713 -459
- 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 +590 -287
- 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 +1358 -874
- 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/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- 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/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- 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/BlockingOperation.swift +29 -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/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -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/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- 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.5.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: {
|
|
@@ -5083,6 +5074,19 @@ var package_default = {
|
|
|
5083
5074
|
"./sdk": {
|
|
5084
5075
|
import: "./dist/sdk/index.js",
|
|
5085
5076
|
types: "./dist/sdk/index.d.ts"
|
|
5077
|
+
},
|
|
5078
|
+
"./contracts/hosted-v1": {
|
|
5079
|
+
import: "./dist/contracts/hosted-v1.js",
|
|
5080
|
+
types: "./dist/contracts/hosted-v1.d.ts"
|
|
5081
|
+
},
|
|
5082
|
+
"./contracts/stream-v1": {
|
|
5083
|
+
import: "./dist/contracts/stream-v1.js",
|
|
5084
|
+
types: "./dist/contracts/stream-v1.d.ts"
|
|
5085
|
+
},
|
|
5086
|
+
"./contracts/fixtures/v1": "./contracts/v1/fixtures.json",
|
|
5087
|
+
"./hosted": {
|
|
5088
|
+
import: "./dist/hosted/index.js",
|
|
5089
|
+
types: "./dist/hosted/index.d.ts"
|
|
5086
5090
|
}
|
|
5087
5091
|
},
|
|
5088
5092
|
engines: {
|
|
@@ -5091,20 +5095,20 @@ var package_default = {
|
|
|
5091
5095
|
scripts: {
|
|
5092
5096
|
clean: "rm -rf dist",
|
|
5093
5097
|
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",
|
|
5098
|
+
"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",
|
|
5099
|
+
"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",
|
|
5100
|
+
"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",
|
|
5101
|
+
"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 && bun run build:contracts",
|
|
5098
5102
|
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
5099
5103
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
5100
5104
|
migrate: "bun run scripts/migrate.ts",
|
|
5101
5105
|
typecheck: "tsc --noEmit",
|
|
5102
5106
|
"typecheck:tcc-contract": "tsc --noEmit -p tsconfig.tcc-contract.json",
|
|
5103
|
-
test: "bun
|
|
5107
|
+
test: "bun scripts/release-suite-gate.ts --all",
|
|
5104
5108
|
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
5105
5109
|
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
5106
5110
|
"verify:ci-native": "bun scripts/ci-native-build.ts",
|
|
5107
|
-
"test:gated":
|
|
5111
|
+
"test:gated": "bun scripts/release-suite-gate.ts",
|
|
5108
5112
|
"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
5113
|
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
5110
5114
|
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
@@ -5122,11 +5126,16 @@ var package_default = {
|
|
|
5122
5126
|
"prepack:platform-gate": `bash -c 'if [ "$(uname -s)" = Darwin ]; then bun run build:native-fs-guard; else echo "WARN: native fs-guard build skipped on $(uname -s) \u2014 dry-run/CI pack; publish recordings from macOS"; fi'`,
|
|
5123
5127
|
prepublishOnly: "bun run typecheck && bun run release-suite-gate",
|
|
5124
5128
|
"release-suite-gate": "bun run scripts/release-suite-gate.ts",
|
|
5125
|
-
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json"
|
|
5129
|
+
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json",
|
|
5130
|
+
"build:contracts": "bun build src/contracts/hosted-v1.ts src/contracts/stream-v1.ts src/hosted/index.ts --target=bun --root src --outdir=dist --splitting --external=@hasna/contracts"
|
|
5126
5131
|
},
|
|
5127
5132
|
files: [
|
|
5128
5133
|
"dist/",
|
|
5134
|
+
"!dist/__tests__/",
|
|
5129
5135
|
"scripts/",
|
|
5136
|
+
"!scripts/test-recorder-fixtures.ts",
|
|
5137
|
+
"!scripts/**/__pycache__/",
|
|
5138
|
+
"!scripts/**/*.pyc",
|
|
5130
5139
|
"Dockerfile.package",
|
|
5131
5140
|
"bun.lock",
|
|
5132
5141
|
"src/native/Recordings/App/",
|
|
@@ -5138,10 +5147,14 @@ var package_default = {
|
|
|
5138
5147
|
"src/native/Recordings/build.sh",
|
|
5139
5148
|
"packaging/macos/",
|
|
5140
5149
|
"README.md",
|
|
5141
|
-
"LICENSE"
|
|
5150
|
+
"LICENSE",
|
|
5151
|
+
"contracts/v1/fixtures.json",
|
|
5152
|
+
"docs/wire-contracts.md",
|
|
5153
|
+
"docs/hosted-sdk.md"
|
|
5142
5154
|
],
|
|
5143
5155
|
dependencies: {
|
|
5144
|
-
"@
|
|
5156
|
+
"@aws-sdk/client-s3": "^3.1007.0",
|
|
5157
|
+
"@hasna/contracts": "1.0.2",
|
|
5145
5158
|
"@hasna/events": "0.1.11",
|
|
5146
5159
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
5147
5160
|
chalk: "^5.4.1",
|
|
@@ -5151,7 +5164,7 @@ var package_default = {
|
|
|
5151
5164
|
zod: "^3.24.2"
|
|
5152
5165
|
},
|
|
5153
5166
|
devDependencies: {
|
|
5154
|
-
"@types/bun": "
|
|
5167
|
+
"@types/bun": "1.3.14",
|
|
5155
5168
|
"@types/pg": "^8.11.11",
|
|
5156
5169
|
"node-api-headers": "1.9.0",
|
|
5157
5170
|
typescript: "^5.8.2"
|
|
@@ -5175,8 +5188,12 @@ function saveFeedback(input) {
|
|
|
5175
5188
|
|
|
5176
5189
|
// ../contracts/dist/client/transport.js
|
|
5177
5190
|
import { isIP } from "net";
|
|
5178
|
-
import {
|
|
5179
|
-
import {
|
|
5191
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
5192
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync2 } from "fs";
|
|
5193
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
5194
|
+
import { createRequire } from "module";
|
|
5195
|
+
import { hostname as osHostname } from "os";
|
|
5196
|
+
import { isAbsolute as isAbsolute2, join as join3 } from "path";
|
|
5180
5197
|
function envToken(name) {
|
|
5181
5198
|
return name.toUpperCase().replace(/-/g, "_");
|
|
5182
5199
|
}
|
|
@@ -5191,6 +5208,9 @@ function credentialOverrideEnvKey(name) {
|
|
|
5191
5208
|
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
5192
5209
|
}
|
|
5193
5210
|
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
5211
|
+
function credentialPointerEnvKey(name) {
|
|
5212
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
5213
|
+
}
|
|
5194
5214
|
|
|
5195
5215
|
class CredentialResolutionError extends Error {
|
|
5196
5216
|
appName;
|
|
@@ -5202,34 +5222,70 @@ class CredentialResolutionError extends Error {
|
|
|
5202
5222
|
this.attempted = attempted;
|
|
5203
5223
|
}
|
|
5204
5224
|
}
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5225
|
+
|
|
5226
|
+
class CredentialFileUnsafeError extends Error {
|
|
5227
|
+
path;
|
|
5228
|
+
constructor(path, reason) {
|
|
5229
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
5230
|
+
this.name = "CredentialFileUnsafeError";
|
|
5231
|
+
this.path = path;
|
|
5232
|
+
}
|
|
5233
|
+
}
|
|
5234
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
5235
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
5236
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
5237
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
5238
|
+
var CONFIG_SUBDIR = "config";
|
|
5239
|
+
var CREDENTIALS_FILE = "credentials";
|
|
5240
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
5241
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
5242
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
5243
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
5209
5244
|
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
5210
5245
|
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
5211
5246
|
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
5212
5247
|
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
5248
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
5213
5249
|
function homeDir(env) {
|
|
5214
5250
|
const home = env.HOME?.trim();
|
|
5215
5251
|
return home ? home : null;
|
|
5216
5252
|
}
|
|
5217
|
-
function
|
|
5218
|
-
|
|
5253
|
+
function absoluteOverride(env, key) {
|
|
5254
|
+
const value = env[key]?.trim();
|
|
5255
|
+
return value && isAbsolute2(value) ? value : null;
|
|
5219
5256
|
}
|
|
5220
|
-
function
|
|
5257
|
+
function hasnaHomeDir(env) {
|
|
5258
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
5259
|
+
if (override)
|
|
5260
|
+
return override;
|
|
5221
5261
|
const home = homeDir(env);
|
|
5222
|
-
|
|
5262
|
+
return home ? join3(home, HASNA_HOME_DIR) : null;
|
|
5263
|
+
}
|
|
5264
|
+
function appConfigDir(name, env) {
|
|
5265
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
5266
|
+
if (configRoot)
|
|
5267
|
+
return join3(configRoot, name);
|
|
5268
|
+
const root = hasnaHomeDir(env);
|
|
5269
|
+
return root ? join3(root, name, CONFIG_SUBDIR) : null;
|
|
5270
|
+
}
|
|
5271
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
5272
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
5223
5273
|
return [];
|
|
5224
|
-
const
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5274
|
+
const directory = appConfigDir(name, env);
|
|
5275
|
+
if (!directory)
|
|
5276
|
+
return [];
|
|
5277
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
5278
|
+
return [{ path: join3(directory, file), tier: "disk" }];
|
|
5279
|
+
}
|
|
5280
|
+
function credentialDiskSources(name, env) {
|
|
5281
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
5282
|
+
}
|
|
5283
|
+
function profileDiskSources(name, env, profile) {
|
|
5284
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
5230
5285
|
}
|
|
5231
5286
|
function parseEnvFile(text) {
|
|
5232
5287
|
const values = new Map;
|
|
5288
|
+
const unusable = new Set;
|
|
5233
5289
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
5234
5290
|
const line = rawLine.trim();
|
|
5235
5291
|
if (line.length === 0 || line.startsWith("#"))
|
|
@@ -5244,38 +5300,81 @@ function parseEnvFile(text) {
|
|
|
5244
5300
|
let value = withoutExport.slice(equals + 1).trim();
|
|
5245
5301
|
const quote = value[0];
|
|
5246
5302
|
if (quote === '"' || quote === "'") {
|
|
5247
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
5303
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
5304
|
+
unusable.add(key);
|
|
5248
5305
|
continue;
|
|
5306
|
+
}
|
|
5249
5307
|
value = value.slice(1, -1);
|
|
5250
5308
|
}
|
|
5251
|
-
if (value.length === 0)
|
|
5309
|
+
if (value.trim().length === 0) {
|
|
5310
|
+
unusable.add(key);
|
|
5252
5311
|
continue;
|
|
5312
|
+
}
|
|
5313
|
+
if (values.has(key) && values.get(key) !== value)
|
|
5314
|
+
unusable.add(key);
|
|
5253
5315
|
values.set(key, value);
|
|
5254
5316
|
}
|
|
5255
|
-
return values;
|
|
5317
|
+
return { values, unusable };
|
|
5318
|
+
}
|
|
5319
|
+
function configFileModeAllowed(mode) {
|
|
5320
|
+
const permissions = mode & 4095;
|
|
5321
|
+
return permissions === 256 || permissions === 384;
|
|
5322
|
+
}
|
|
5323
|
+
function configFileReadsCoherent(before, after) {
|
|
5324
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
5256
5325
|
}
|
|
5257
5326
|
function readAppConfigFile(path) {
|
|
5258
|
-
|
|
5327
|
+
const unsafe = (reason) => {
|
|
5328
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
5329
|
+
};
|
|
5330
|
+
let fd = -1;
|
|
5259
5331
|
try {
|
|
5260
|
-
|
|
5261
|
-
|
|
5332
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
5333
|
+
} catch (error) {
|
|
5334
|
+
const code = error.code;
|
|
5335
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
5262
5336
|
return null;
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5337
|
+
if (code === "ELOOP")
|
|
5338
|
+
unsafe("the path is a symlink");
|
|
5339
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
5340
|
+
}
|
|
5341
|
+
try {
|
|
5342
|
+
const before = fstatSync(fd);
|
|
5343
|
+
if (!before.isFile())
|
|
5344
|
+
unsafe("the path is not a regular file");
|
|
5345
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
5346
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
5347
|
+
}
|
|
5348
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
5349
|
+
if (uid !== undefined && before.uid !== uid)
|
|
5350
|
+
unsafe("the file is not owned by the current user");
|
|
5351
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
5352
|
+
unsafe("the file exceeds the size limit");
|
|
5353
|
+
const bytes = readFileSync2(fd);
|
|
5354
|
+
const after = fstatSync(fd);
|
|
5355
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
5356
|
+
unsafe("the file changed while being read");
|
|
5357
|
+
}
|
|
5358
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
5359
|
+
} finally {
|
|
5360
|
+
if (fd !== -1)
|
|
5361
|
+
closeSync(fd);
|
|
5266
5362
|
}
|
|
5267
|
-
return parseEnvFile(text);
|
|
5268
5363
|
}
|
|
5269
5364
|
function readCredentialFile(path, apiKeyKeys) {
|
|
5270
|
-
const
|
|
5271
|
-
if (!
|
|
5365
|
+
const parsed = readAppConfigFile(path);
|
|
5366
|
+
if (!parsed)
|
|
5272
5367
|
return null;
|
|
5273
5368
|
for (const key of apiKeyKeys) {
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5369
|
+
if (parsed.unusable.has(key)) {
|
|
5370
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
5371
|
+
}
|
|
5277
5372
|
}
|
|
5278
|
-
|
|
5373
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
5374
|
+
if (new Set(values).size > 1) {
|
|
5375
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
5376
|
+
}
|
|
5377
|
+
return values[0] ?? null;
|
|
5279
5378
|
}
|
|
5280
5379
|
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
5281
5380
|
function appConfigDiskValue(name, env, keys) {
|
|
@@ -5283,11 +5382,19 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
5283
5382
|
if (wanted.length === 0)
|
|
5284
5383
|
return null;
|
|
5285
5384
|
for (const path of credentialDiskSources(name, env)) {
|
|
5286
|
-
const
|
|
5287
|
-
if (!
|
|
5385
|
+
const parsed = readAppConfigFile(path);
|
|
5386
|
+
if (!parsed)
|
|
5288
5387
|
continue;
|
|
5388
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
5389
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
5390
|
+
}
|
|
5391
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
5392
|
+
if (new Set(values).size > 1)
|
|
5393
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
5289
5394
|
for (const key of wanted) {
|
|
5290
|
-
|
|
5395
|
+
if (parsed.unusable.has(key))
|
|
5396
|
+
return { key, value: "", path, unusable: true };
|
|
5397
|
+
const value = parsed.values.get(key)?.trim();
|
|
5291
5398
|
if (value)
|
|
5292
5399
|
return { key, value, path };
|
|
5293
5400
|
}
|
|
@@ -5295,20 +5402,21 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
5295
5402
|
return null;
|
|
5296
5403
|
}
|
|
5297
5404
|
function assertUsableCredential(appName, source, value) {
|
|
5405
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
5406
|
+
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]);
|
|
5407
|
+
}
|
|
5298
5408
|
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
5299
5409
|
return;
|
|
5300
5410
|
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
5411
|
}
|
|
5302
5412
|
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
5303
5413
|
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
5304
|
-
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
5305
5414
|
function sealCredential(fields) {
|
|
5306
5415
|
const { apiKey } = fields;
|
|
5307
5416
|
const visible = {
|
|
5308
5417
|
tier: fields.tier,
|
|
5309
5418
|
source: fields.source,
|
|
5310
5419
|
deliberate: fields.deliberate,
|
|
5311
|
-
deprecated: fields.deprecated,
|
|
5312
5420
|
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
5313
5421
|
warning: fields.warning
|
|
5314
5422
|
};
|
|
@@ -5319,6 +5427,14 @@ function sealCredential(fields) {
|
|
|
5319
5427
|
writable: false,
|
|
5320
5428
|
configurable: false
|
|
5321
5429
|
});
|
|
5430
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
5431
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
5432
|
+
value: fields.pointerVaultKey,
|
|
5433
|
+
enumerable: false,
|
|
5434
|
+
writable: false,
|
|
5435
|
+
configurable: false
|
|
5436
|
+
});
|
|
5437
|
+
}
|
|
5322
5438
|
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
5323
5439
|
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
5324
5440
|
enumerable: false,
|
|
@@ -5333,198 +5449,1105 @@ function sealCredential(fields) {
|
|
|
5333
5449
|
});
|
|
5334
5450
|
return Object.freeze(sealed);
|
|
5335
5451
|
}
|
|
5452
|
+
function firstEnvValue(env, keys) {
|
|
5453
|
+
for (const key of keys) {
|
|
5454
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
5455
|
+
continue;
|
|
5456
|
+
const value = env[key]?.trim();
|
|
5457
|
+
if (value)
|
|
5458
|
+
return { key, value };
|
|
5459
|
+
}
|
|
5460
|
+
return null;
|
|
5461
|
+
}
|
|
5462
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
5463
|
+
function isAmbientEnvironment(env) {
|
|
5464
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
5465
|
+
}
|
|
5466
|
+
function defaultKeychainRunner(argv) {
|
|
5467
|
+
const result = spawnSync2(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
5468
|
+
encoding: "utf8",
|
|
5469
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
5470
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
5471
|
+
});
|
|
5472
|
+
return {
|
|
5473
|
+
status: result.status,
|
|
5474
|
+
stdout: result.stdout ?? "",
|
|
5475
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
5476
|
+
};
|
|
5477
|
+
}
|
|
5478
|
+
function keychainTierEnabled(env, options) {
|
|
5479
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
5480
|
+
return false;
|
|
5481
|
+
if (options.enabled !== undefined)
|
|
5482
|
+
return options.enabled;
|
|
5483
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
5484
|
+
}
|
|
5485
|
+
function keychainAccount(env, options) {
|
|
5486
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
5487
|
+
if (station)
|
|
5488
|
+
return station;
|
|
5489
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
5490
|
+
if (host)
|
|
5491
|
+
return host;
|
|
5492
|
+
const user = env.USER?.trim();
|
|
5493
|
+
return user || null;
|
|
5494
|
+
}
|
|
5495
|
+
function keychainFailureHint(text) {
|
|
5496
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
5497
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
5498
|
+
return clean ? `: ${clean}` : "";
|
|
5499
|
+
}
|
|
5500
|
+
function readKeychainItem(name, env, kind, options) {
|
|
5501
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
5502
|
+
return null;
|
|
5503
|
+
const account = keychainAccount(env, options);
|
|
5504
|
+
if (!account)
|
|
5505
|
+
return null;
|
|
5506
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
5507
|
+
const source = `keychain:${service}@${account}`;
|
|
5508
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
5509
|
+
let result;
|
|
5510
|
+
try {
|
|
5511
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
5512
|
+
} catch (error) {
|
|
5513
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
5514
|
+
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]);
|
|
5515
|
+
}
|
|
5516
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
5517
|
+
return null;
|
|
5518
|
+
if (result.status !== 0) {
|
|
5519
|
+
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]);
|
|
5520
|
+
}
|
|
5521
|
+
const value = result.stdout.trim();
|
|
5522
|
+
if (!value) {
|
|
5523
|
+
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]);
|
|
5524
|
+
}
|
|
5525
|
+
return { value, source };
|
|
5526
|
+
}
|
|
5527
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
5528
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
5529
|
+
}
|
|
5530
|
+
function snapshotClientEnvironment(name, env) {
|
|
5531
|
+
const keys = clientTransportEnvKeys(name);
|
|
5532
|
+
const ambient = isAmbientEnvironment(env);
|
|
5533
|
+
const snapshot = Object.create(null);
|
|
5534
|
+
for (const key of [
|
|
5535
|
+
...keys.apiUrlKeys,
|
|
5536
|
+
...keys.apiKeyKeys,
|
|
5537
|
+
credentialOverrideEnvKey(name),
|
|
5538
|
+
credentialPointerEnvKey(name),
|
|
5539
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
5540
|
+
"HOME",
|
|
5541
|
+
HASNA_HOME_ENV_KEY,
|
|
5542
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
5543
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
5544
|
+
"USER"
|
|
5545
|
+
]) {
|
|
5546
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
5547
|
+
if (!descriptor)
|
|
5548
|
+
continue;
|
|
5549
|
+
if (!("value" in descriptor)) {
|
|
5550
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
5551
|
+
}
|
|
5552
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
5553
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
5554
|
+
}
|
|
5555
|
+
snapshot[key] = descriptor.value;
|
|
5556
|
+
}
|
|
5557
|
+
if (ambient) {
|
|
5558
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
5559
|
+
value: true,
|
|
5560
|
+
enumerable: false,
|
|
5561
|
+
writable: false,
|
|
5562
|
+
configurable: false
|
|
5563
|
+
});
|
|
5564
|
+
}
|
|
5565
|
+
return Object.freeze(snapshot);
|
|
5566
|
+
}
|
|
5567
|
+
function resolveCredential(name, env, options = {}) {
|
|
5568
|
+
env = snapshotClientEnvironment(name, env);
|
|
5569
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
5570
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
5571
|
+
if (options.apiKey !== undefined) {
|
|
5572
|
+
const explicitKey = options.apiKey.trim();
|
|
5573
|
+
if (!explicitKey) {
|
|
5574
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
5575
|
+
}
|
|
5576
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
5577
|
+
return sealCredential({
|
|
5578
|
+
apiKey: explicitKey,
|
|
5579
|
+
tier: "argument",
|
|
5580
|
+
source: "explicit apiKey argument",
|
|
5581
|
+
deliberate: true,
|
|
5582
|
+
diskCandidates: diskPaths,
|
|
5583
|
+
warning: null
|
|
5584
|
+
});
|
|
5585
|
+
}
|
|
5586
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
5587
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
5588
|
+
if (overrideRaw !== undefined) {
|
|
5589
|
+
const override = overrideRaw.trim();
|
|
5590
|
+
if (!override) {
|
|
5591
|
+
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]);
|
|
5592
|
+
}
|
|
5593
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
5594
|
+
return sealCredential({
|
|
5595
|
+
apiKey: override,
|
|
5596
|
+
tier: "override",
|
|
5597
|
+
source: overrideKeyName,
|
|
5598
|
+
deliberate: true,
|
|
5599
|
+
diskCandidates: diskPaths,
|
|
5600
|
+
warning: null
|
|
5601
|
+
});
|
|
5602
|
+
}
|
|
5603
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
5604
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
5605
|
+
if (pointerRaw !== undefined) {
|
|
5606
|
+
const pointer = pointerRaw.trim();
|
|
5607
|
+
if (!pointer) {
|
|
5608
|
+
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]);
|
|
5609
|
+
}
|
|
5610
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
5611
|
+
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]);
|
|
5612
|
+
}
|
|
5613
|
+
return sealCredential({
|
|
5614
|
+
apiKey: "",
|
|
5615
|
+
pointerVaultKey: pointer,
|
|
5616
|
+
tier: "pointer",
|
|
5617
|
+
source: pointerKeyName,
|
|
5618
|
+
deliberate: true,
|
|
5619
|
+
diskCandidates: diskPaths,
|
|
5620
|
+
warning: null
|
|
5621
|
+
});
|
|
5622
|
+
}
|
|
5623
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
5624
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
5625
|
+
}
|
|
5626
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
5627
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
5628
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
5629
|
+
}
|
|
5630
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
5631
|
+
if (profile) {
|
|
5632
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
5633
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
5634
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
5635
|
+
}
|
|
5636
|
+
const paths = profileDiskSources(name, env, profile);
|
|
5637
|
+
for (const path of paths) {
|
|
5638
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
5639
|
+
if (value) {
|
|
5640
|
+
assertUsableCredential(name, path, value);
|
|
5641
|
+
return sealCredential({
|
|
5642
|
+
apiKey: value,
|
|
5643
|
+
tier: "profile",
|
|
5644
|
+
source: path,
|
|
5645
|
+
deliberate: true,
|
|
5646
|
+
diskCandidates: paths,
|
|
5647
|
+
warning: null
|
|
5648
|
+
});
|
|
5649
|
+
}
|
|
5650
|
+
}
|
|
5651
|
+
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);
|
|
5652
|
+
}
|
|
5653
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
5654
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
5655
|
+
if (blankEnv) {
|
|
5656
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
5657
|
+
}
|
|
5658
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
5659
|
+
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));
|
|
5660
|
+
}
|
|
5661
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
5662
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
5663
|
+
if (keychainHit) {
|
|
5664
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
5665
|
+
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;
|
|
5666
|
+
return sealCredential({
|
|
5667
|
+
apiKey: keychainHit.value,
|
|
5668
|
+
tier: "keychain",
|
|
5669
|
+
source: keychainHit.source,
|
|
5670
|
+
deliberate: false,
|
|
5671
|
+
diskCandidates: diskPaths,
|
|
5672
|
+
warning
|
|
5673
|
+
});
|
|
5674
|
+
}
|
|
5675
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
5676
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
5677
|
+
if (diskHits.length > 0) {
|
|
5678
|
+
const winner = diskHits[0];
|
|
5679
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
5680
|
+
const divergentSources = [
|
|
5681
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
5682
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
5683
|
+
];
|
|
5684
|
+
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;
|
|
5685
|
+
return sealCredential({
|
|
5686
|
+
apiKey: winner.value,
|
|
5687
|
+
tier: winner.src.tier,
|
|
5688
|
+
source: winner.src.path,
|
|
5689
|
+
deliberate: false,
|
|
5690
|
+
diskCandidates: diskPaths,
|
|
5691
|
+
warning
|
|
5692
|
+
});
|
|
5693
|
+
}
|
|
5694
|
+
if (envHit) {
|
|
5695
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
5696
|
+
return sealCredential({
|
|
5697
|
+
apiKey: envHit.value,
|
|
5698
|
+
tier: "env",
|
|
5699
|
+
source: envHit.key,
|
|
5700
|
+
deliberate: false,
|
|
5701
|
+
diskCandidates: diskPaths,
|
|
5702
|
+
warning: null
|
|
5703
|
+
});
|
|
5704
|
+
}
|
|
5705
|
+
return null;
|
|
5706
|
+
}
|
|
5707
|
+
var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
5708
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
5709
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
|
|
5710
|
+
var DEFAULT_AUTHORITY_SOURCE = "default";
|
|
5711
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
5712
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
5713
|
+
}
|
|
5714
|
+
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
5715
|
+
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
5716
|
+
function isValidDnsDomain(value) {
|
|
5717
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
5718
|
+
return false;
|
|
5719
|
+
}
|
|
5720
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
5721
|
+
}
|
|
5722
|
+
function validateAppSlug(name) {
|
|
5723
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
5724
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
5725
|
+
}
|
|
5726
|
+
return name;
|
|
5727
|
+
}
|
|
5728
|
+
function rawAuthority(value) {
|
|
5729
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
5730
|
+
if (!match)
|
|
5731
|
+
throw new Error("API URL must be absolute.");
|
|
5732
|
+
const afterScheme = value.slice(match[0].length);
|
|
5733
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
5734
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
5735
|
+
if (!authority)
|
|
5736
|
+
throw new Error("API URL must include a hostname.");
|
|
5737
|
+
return authority;
|
|
5738
|
+
}
|
|
5739
|
+
function assertCanonicalPort(port) {
|
|
5740
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
5741
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
5742
|
+
}
|
|
5743
|
+
const numericPort = Number(port);
|
|
5744
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
5745
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
5746
|
+
}
|
|
5747
|
+
}
|
|
5748
|
+
function canonicalAuthorityHostname(authority) {
|
|
5749
|
+
let rawHostname;
|
|
5750
|
+
if (authority.startsWith("[")) {
|
|
5751
|
+
const closingBracket = authority.indexOf("]");
|
|
5752
|
+
if (closingBracket === -1) {
|
|
5753
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
5754
|
+
}
|
|
5755
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
5756
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
5757
|
+
if (portSuffix) {
|
|
5758
|
+
if (!portSuffix.startsWith(":")) {
|
|
5759
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
5760
|
+
}
|
|
5761
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
5762
|
+
}
|
|
5763
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
5764
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
5765
|
+
}
|
|
5766
|
+
} else {
|
|
5767
|
+
const firstColon = authority.indexOf(":");
|
|
5768
|
+
const lastColon = authority.lastIndexOf(":");
|
|
5769
|
+
if (firstColon !== lastColon) {
|
|
5770
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
5771
|
+
}
|
|
5772
|
+
if (lastColon !== -1) {
|
|
5773
|
+
const port = authority.slice(lastColon + 1);
|
|
5774
|
+
assertCanonicalPort(port);
|
|
5775
|
+
rawHostname = authority.slice(0, lastColon);
|
|
5776
|
+
} else {
|
|
5777
|
+
rawHostname = authority;
|
|
5778
|
+
}
|
|
5779
|
+
const ipVersion = isIP(rawHostname);
|
|
5780
|
+
const numericAddressParts = rawHostname.split(".");
|
|
5781
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
5782
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
5783
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5786
|
+
return rawHostname.toLowerCase();
|
|
5787
|
+
}
|
|
5788
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
5789
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
5790
|
+
}
|
|
5791
|
+
function toV1BaseUrl(apiUrl) {
|
|
5792
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
5793
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
5794
|
+
}
|
|
5795
|
+
const input = apiUrl.trim();
|
|
5796
|
+
const authority = rawAuthority(input);
|
|
5797
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
5798
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
5799
|
+
}
|
|
5800
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
5801
|
+
const url = new URL(input);
|
|
5802
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
5803
|
+
throw new Error("API URL must use http or https.");
|
|
5804
|
+
}
|
|
5805
|
+
if (url.username || url.password) {
|
|
5806
|
+
throw new Error("API URL must not include credentials.");
|
|
5807
|
+
}
|
|
5808
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
5809
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
5810
|
+
}
|
|
5811
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
5812
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
5813
|
+
}
|
|
5814
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
5815
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
5816
|
+
}
|
|
5817
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
5818
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
5819
|
+
}
|
|
5820
|
+
if (url.search || url.hash) {
|
|
5821
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
5822
|
+
}
|
|
5823
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
5824
|
+
if (path.endsWith("/v1"))
|
|
5825
|
+
path = path.slice(0, -"/v1".length);
|
|
5826
|
+
url.pathname = `${path}/v1`;
|
|
5827
|
+
return url.toString().replace(/\/+$/, "");
|
|
5828
|
+
}
|
|
5829
|
+
class ClientTransportConfigurationError extends Error {
|
|
5830
|
+
appName;
|
|
5831
|
+
sources;
|
|
5832
|
+
constructor(appName, message, sources = []) {
|
|
5833
|
+
super(message);
|
|
5834
|
+
this.name = "ClientTransportConfigurationError";
|
|
5835
|
+
this.appName = appName;
|
|
5836
|
+
this.sources = Object.freeze([...sources]);
|
|
5837
|
+
}
|
|
5838
|
+
}
|
|
5839
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
5840
|
+
env = snapshotClientEnvironment(name, env);
|
|
5841
|
+
const keys = clientTransportEnvKeys(name);
|
|
5842
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
5843
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
5844
|
+
if (blankUrl) {
|
|
5845
|
+
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]);
|
|
5846
|
+
}
|
|
5847
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
5848
|
+
if (controlledUrl) {
|
|
5849
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
5850
|
+
}
|
|
5851
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
5852
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
5853
|
+
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));
|
|
5854
|
+
}
|
|
5855
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
5856
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
5857
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
5858
|
+
if (diskConfigUrlHit?.unusable) {
|
|
5859
|
+
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]);
|
|
5860
|
+
}
|
|
5861
|
+
const urlCandidates = [
|
|
5862
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
5863
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
5864
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
5865
|
+
];
|
|
5866
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
5867
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
5868
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
5869
|
+
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));
|
|
5870
|
+
}
|
|
5871
|
+
const warnings = [];
|
|
5872
|
+
if (configuredUrl && !envUrlHit) {
|
|
5873
|
+
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.`);
|
|
5874
|
+
}
|
|
5875
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
5876
|
+
if (!credential) {
|
|
5877
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
5878
|
+
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`;
|
|
5879
|
+
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.`);
|
|
5880
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
5881
|
+
}
|
|
5882
|
+
if (credential.warning)
|
|
5883
|
+
warnings.push(credential.warning);
|
|
5884
|
+
let urlHit;
|
|
5885
|
+
if (configuredUrl) {
|
|
5886
|
+
urlHit = configuredUrl;
|
|
5887
|
+
} else {
|
|
5888
|
+
try {
|
|
5889
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
5890
|
+
} catch (error) {
|
|
5891
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5892
|
+
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]]);
|
|
5893
|
+
}
|
|
5894
|
+
}
|
|
5895
|
+
const apiUrlSource = urlHit.key;
|
|
5896
|
+
let baseUrl;
|
|
5897
|
+
try {
|
|
5898
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
5899
|
+
} catch (error) {
|
|
5900
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5901
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
5902
|
+
}
|
|
5903
|
+
return {
|
|
5904
|
+
resolution: {
|
|
5905
|
+
transport: "http",
|
|
5906
|
+
transportSource: urlHit.key,
|
|
5907
|
+
baseUrl,
|
|
5908
|
+
apiUrlSource,
|
|
5909
|
+
apiKeyPresent: true,
|
|
5910
|
+
apiKeySource: credential.source,
|
|
5911
|
+
apiKeyTier: credential.tier,
|
|
5912
|
+
misconfigured: false,
|
|
5913
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
5914
|
+
},
|
|
5915
|
+
credential
|
|
5916
|
+
};
|
|
5917
|
+
}
|
|
5918
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
5919
|
+
return resolveClientTransportSnapshot(name, env, options).resolution;
|
|
5920
|
+
}
|
|
5921
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
5922
|
+
const paths = credentialDiskSources(name, env);
|
|
5923
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
5924
|
+
}
|
|
5925
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
5926
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
5927
|
+
"host",
|
|
5928
|
+
":authority",
|
|
5929
|
+
"forwarded",
|
|
5930
|
+
"x-forwarded-host",
|
|
5931
|
+
"x-original-host"
|
|
5932
|
+
]);
|
|
5933
|
+
|
|
5934
|
+
// ../contracts/dist/client/storage.js
|
|
5935
|
+
import { isIP as isIP2 } from "net";
|
|
5936
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
5937
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync3 } from "fs";
|
|
5938
|
+
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|
|
5939
|
+
import { createRequire as createRequire2 } from "module";
|
|
5940
|
+
import { hostname as osHostname2 } from "os";
|
|
5941
|
+
import { isAbsolute as isAbsolute3, join as join4 } from "path";
|
|
5942
|
+
function envToken2(name) {
|
|
5943
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
5944
|
+
}
|
|
5945
|
+
function clientTransportEnvKeys2(name) {
|
|
5946
|
+
const envSegment = envToken2(name);
|
|
5947
|
+
return {
|
|
5948
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
5949
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
5950
|
+
};
|
|
5951
|
+
}
|
|
5952
|
+
function credentialOverrideEnvKey2(name) {
|
|
5953
|
+
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
5954
|
+
}
|
|
5955
|
+
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
5956
|
+
function credentialPointerEnvKey2(name) {
|
|
5957
|
+
return `HASNA_${envToken2(name)}_API_KEY_REF`;
|
|
5958
|
+
}
|
|
5959
|
+
|
|
5960
|
+
class CredentialResolutionError2 extends Error {
|
|
5961
|
+
appName;
|
|
5962
|
+
attempted;
|
|
5963
|
+
constructor(appName, message, attempted) {
|
|
5964
|
+
super(message);
|
|
5965
|
+
this.name = "CredentialResolutionError";
|
|
5966
|
+
this.appName = appName;
|
|
5967
|
+
this.attempted = attempted;
|
|
5968
|
+
}
|
|
5969
|
+
}
|
|
5970
|
+
|
|
5971
|
+
class CredentialFileUnsafeError2 extends Error {
|
|
5972
|
+
path;
|
|
5973
|
+
constructor(path, reason) {
|
|
5974
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
5975
|
+
this.name = "CredentialFileUnsafeError";
|
|
5976
|
+
this.path = path;
|
|
5977
|
+
}
|
|
5978
|
+
}
|
|
5979
|
+
var HASNA_HOME_ENV_KEY2 = "HASNA_HOME";
|
|
5980
|
+
var HASNA_CONFIG_HOME_ENV_KEY2 = "HASNA_CONFIG_HOME";
|
|
5981
|
+
var KEYCHAIN_STATION_ENV_KEY2 = "HASNA_STATION";
|
|
5982
|
+
var HASNA_HOME_DIR2 = ".hasna";
|
|
5983
|
+
var CONFIG_SUBDIR2 = "config";
|
|
5984
|
+
var CREDENTIALS_FILE2 = "credentials";
|
|
5985
|
+
var KEYCHAIN_SECURITY_BIN2 = "/usr/bin/security";
|
|
5986
|
+
var KEYCHAIN_SERVICE_PREFIX2 = "hasna.credentials";
|
|
5987
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS2 = 44;
|
|
5988
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS2 = 1e4;
|
|
5989
|
+
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
5990
|
+
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
5991
|
+
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
5992
|
+
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
5993
|
+
var VAULT_POINTER_SHAPE2 = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
5994
|
+
function homeDir2(env) {
|
|
5995
|
+
const home = env.HOME?.trim();
|
|
5996
|
+
return home ? home : null;
|
|
5997
|
+
}
|
|
5998
|
+
function absoluteOverride2(env, key) {
|
|
5999
|
+
const value = env[key]?.trim();
|
|
6000
|
+
return value && isAbsolute3(value) ? value : null;
|
|
6001
|
+
}
|
|
6002
|
+
function hasnaHomeDir2(env) {
|
|
6003
|
+
const override = absoluteOverride2(env, HASNA_HOME_ENV_KEY2);
|
|
6004
|
+
if (override)
|
|
6005
|
+
return override;
|
|
6006
|
+
const home = homeDir2(env);
|
|
6007
|
+
return home ? join4(home, HASNA_HOME_DIR2) : null;
|
|
6008
|
+
}
|
|
6009
|
+
function appConfigDir2(name, env) {
|
|
6010
|
+
const configRoot = absoluteOverride2(env, HASNA_CONFIG_HOME_ENV_KEY2);
|
|
6011
|
+
if (configRoot)
|
|
6012
|
+
return join4(configRoot, name);
|
|
6013
|
+
const root = hasnaHomeDir2(env);
|
|
6014
|
+
return root ? join4(root, name, CONFIG_SUBDIR2) : null;
|
|
6015
|
+
}
|
|
6016
|
+
function credentialDiskSourceList2(name, env, profile = null) {
|
|
6017
|
+
if (!SAFE_APP_SLUG2.test(name))
|
|
6018
|
+
return [];
|
|
6019
|
+
const directory = appConfigDir2(name, env);
|
|
6020
|
+
if (!directory)
|
|
6021
|
+
return [];
|
|
6022
|
+
const file = profile ? `${CREDENTIALS_FILE2}-${profile}` : CREDENTIALS_FILE2;
|
|
6023
|
+
return [{ path: join4(directory, file), tier: "disk" }];
|
|
6024
|
+
}
|
|
6025
|
+
function credentialDiskSources2(name, env) {
|
|
6026
|
+
return credentialDiskSourceList2(name, env, null).map((s) => s.path);
|
|
6027
|
+
}
|
|
6028
|
+
function profileDiskSources2(name, env, profile) {
|
|
6029
|
+
return credentialDiskSourceList2(name, env, profile).map((s) => s.path);
|
|
6030
|
+
}
|
|
6031
|
+
function parseEnvFile2(text) {
|
|
6032
|
+
const values = new Map;
|
|
6033
|
+
const unusable = new Set;
|
|
6034
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
6035
|
+
const line = rawLine.trim();
|
|
6036
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
6037
|
+
continue;
|
|
6038
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
6039
|
+
const equals = withoutExport.indexOf("=");
|
|
6040
|
+
if (equals <= 0)
|
|
6041
|
+
continue;
|
|
6042
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
6043
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
6044
|
+
continue;
|
|
6045
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
6046
|
+
const quote = value[0];
|
|
6047
|
+
if (quote === '"' || quote === "'") {
|
|
6048
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
6049
|
+
unusable.add(key);
|
|
6050
|
+
continue;
|
|
6051
|
+
}
|
|
6052
|
+
value = value.slice(1, -1);
|
|
6053
|
+
}
|
|
6054
|
+
if (value.trim().length === 0) {
|
|
6055
|
+
unusable.add(key);
|
|
6056
|
+
continue;
|
|
6057
|
+
}
|
|
6058
|
+
if (values.has(key) && values.get(key) !== value)
|
|
6059
|
+
unusable.add(key);
|
|
6060
|
+
values.set(key, value);
|
|
6061
|
+
}
|
|
6062
|
+
return { values, unusable };
|
|
6063
|
+
}
|
|
6064
|
+
function configFileModeAllowed2(mode) {
|
|
6065
|
+
const permissions = mode & 4095;
|
|
6066
|
+
return permissions === 256 || permissions === 384;
|
|
6067
|
+
}
|
|
6068
|
+
function configFileReadsCoherent2(before, after) {
|
|
6069
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
6070
|
+
}
|
|
6071
|
+
function readAppConfigFile2(path) {
|
|
6072
|
+
const unsafe = (reason) => {
|
|
6073
|
+
throw new CredentialFileUnsafeError2(path, reason);
|
|
6074
|
+
};
|
|
6075
|
+
let fd = -1;
|
|
6076
|
+
try {
|
|
6077
|
+
fd = openSync2(path, O_RDONLY2 | O_NOFOLLOW2 | O_NONBLOCK2);
|
|
6078
|
+
} catch (error) {
|
|
6079
|
+
const code = error.code;
|
|
6080
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
6081
|
+
return null;
|
|
6082
|
+
if (code === "ELOOP")
|
|
6083
|
+
unsafe("the path is a symlink");
|
|
6084
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
6085
|
+
}
|
|
6086
|
+
try {
|
|
6087
|
+
const before = fstatSync2(fd);
|
|
6088
|
+
if (!before.isFile())
|
|
6089
|
+
unsafe("the path is not a regular file");
|
|
6090
|
+
if (!configFileModeAllowed2(before.mode)) {
|
|
6091
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
6092
|
+
}
|
|
6093
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
6094
|
+
if (uid !== undefined && before.uid !== uid)
|
|
6095
|
+
unsafe("the file is not owned by the current user");
|
|
6096
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
6097
|
+
unsafe("the file exceeds the size limit");
|
|
6098
|
+
const bytes = readFileSync3(fd);
|
|
6099
|
+
const after = fstatSync2(fd);
|
|
6100
|
+
if (!configFileReadsCoherent2(before, after)) {
|
|
6101
|
+
unsafe("the file changed while being read");
|
|
6102
|
+
}
|
|
6103
|
+
return parseEnvFile2(bytes.toString("utf8"));
|
|
6104
|
+
} finally {
|
|
6105
|
+
if (fd !== -1)
|
|
6106
|
+
closeSync2(fd);
|
|
6107
|
+
}
|
|
6108
|
+
}
|
|
6109
|
+
function readCredentialFile2(path, apiKeyKeys) {
|
|
6110
|
+
const parsed = readAppConfigFile2(path);
|
|
6111
|
+
if (!parsed)
|
|
6112
|
+
return null;
|
|
6113
|
+
for (const key of apiKeyKeys) {
|
|
6114
|
+
if (parsed.unusable.has(key)) {
|
|
6115
|
+
throw new CredentialFileUnsafeError2(path, `${key} is declared but blank or malformed`);
|
|
6116
|
+
}
|
|
6117
|
+
}
|
|
6118
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
6119
|
+
if (new Set(values).size > 1) {
|
|
6120
|
+
throw new CredentialFileUnsafeError2(path, "credential aliases disagree");
|
|
6121
|
+
}
|
|
6122
|
+
return values[0] ?? null;
|
|
6123
|
+
}
|
|
6124
|
+
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
6125
|
+
function appConfigDiskValue2(name, env, keys) {
|
|
6126
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY2.test(key));
|
|
6127
|
+
if (wanted.length === 0)
|
|
6128
|
+
return null;
|
|
6129
|
+
for (const path of credentialDiskSources2(name, env)) {
|
|
6130
|
+
const parsed = readAppConfigFile2(path);
|
|
6131
|
+
if (!parsed)
|
|
6132
|
+
continue;
|
|
6133
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
6134
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
6135
|
+
}
|
|
6136
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
6137
|
+
if (new Set(values).size > 1)
|
|
6138
|
+
throw new CredentialFileUnsafeError2(path, "configuration aliases disagree");
|
|
6139
|
+
for (const key of wanted) {
|
|
6140
|
+
if (parsed.unusable.has(key))
|
|
6141
|
+
return { key, value: "", path, unusable: true };
|
|
6142
|
+
const value = parsed.values.get(key)?.trim();
|
|
6143
|
+
if (value)
|
|
6144
|
+
return { key, value, path };
|
|
6145
|
+
}
|
|
6146
|
+
}
|
|
6147
|
+
return null;
|
|
6148
|
+
}
|
|
6149
|
+
function assertUsableCredential2(appName, source, value) {
|
|
6150
|
+
if (VAULT_POINTER_SHAPE2.test(value)) {
|
|
6151
|
+
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]);
|
|
6152
|
+
}
|
|
6153
|
+
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
6154
|
+
return;
|
|
6155
|
+
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]);
|
|
6156
|
+
}
|
|
6157
|
+
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
6158
|
+
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
6159
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
6160
|
+
function sealCredential2(fields) {
|
|
6161
|
+
const { apiKey } = fields;
|
|
6162
|
+
const visible = {
|
|
6163
|
+
tier: fields.tier,
|
|
6164
|
+
source: fields.source,
|
|
6165
|
+
deliberate: fields.deliberate,
|
|
6166
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
6167
|
+
warning: fields.warning
|
|
6168
|
+
};
|
|
6169
|
+
const sealed = { ...visible };
|
|
6170
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
6171
|
+
value: apiKey,
|
|
6172
|
+
enumerable: false,
|
|
6173
|
+
writable: false,
|
|
6174
|
+
configurable: false
|
|
6175
|
+
});
|
|
6176
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
6177
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
6178
|
+
value: fields.pointerVaultKey,
|
|
6179
|
+
enumerable: false,
|
|
6180
|
+
writable: false,
|
|
6181
|
+
configurable: false
|
|
6182
|
+
});
|
|
6183
|
+
}
|
|
6184
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
6185
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
6186
|
+
enumerable: false,
|
|
6187
|
+
writable: false,
|
|
6188
|
+
configurable: false
|
|
6189
|
+
});
|
|
6190
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
|
|
6191
|
+
value: true,
|
|
6192
|
+
enumerable: false,
|
|
6193
|
+
writable: false,
|
|
6194
|
+
configurable: false
|
|
6195
|
+
});
|
|
6196
|
+
return Object.freeze(sealed);
|
|
6197
|
+
}
|
|
5336
6198
|
function isSealedCredential(credential) {
|
|
5337
|
-
return credential[
|
|
6199
|
+
return credential[CREDENTIAL_SEAL2] === true;
|
|
5338
6200
|
}
|
|
5339
6201
|
function explicitCredential(appName, apiKey) {
|
|
5340
6202
|
const source = "explicit apiKey option";
|
|
5341
|
-
|
|
5342
|
-
return
|
|
6203
|
+
assertUsableCredential2(appName, source, apiKey);
|
|
6204
|
+
return sealCredential2({
|
|
5343
6205
|
apiKey,
|
|
5344
6206
|
tier: "argument",
|
|
5345
6207
|
source,
|
|
5346
6208
|
deliberate: true,
|
|
5347
|
-
deprecated: false,
|
|
5348
6209
|
diskCandidates: [],
|
|
5349
6210
|
warning: null
|
|
5350
6211
|
});
|
|
5351
6212
|
}
|
|
5352
6213
|
function validateAndSealResolvedCredential(appName, credential) {
|
|
5353
6214
|
const apiKey = credential.apiKey;
|
|
5354
|
-
|
|
6215
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
5355
6216
|
if (!isSealedCredential(credential)) {
|
|
5356
|
-
return
|
|
6217
|
+
return sealCredential2({
|
|
5357
6218
|
apiKey,
|
|
5358
6219
|
tier: "argument",
|
|
5359
6220
|
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
5360
6221
|
deliberate: true,
|
|
5361
|
-
deprecated: false,
|
|
5362
6222
|
diskCandidates: [],
|
|
5363
6223
|
warning: null
|
|
5364
6224
|
});
|
|
5365
6225
|
}
|
|
5366
|
-
return
|
|
6226
|
+
return sealCredential2({
|
|
5367
6227
|
apiKey,
|
|
5368
6228
|
tier: credential.tier,
|
|
5369
6229
|
source: credential.source,
|
|
5370
6230
|
deliberate: credential.deliberate,
|
|
5371
|
-
deprecated: credential.deprecated,
|
|
5372
6231
|
diskCandidates: credential.diskCandidates,
|
|
5373
|
-
warning: credential.warning
|
|
6232
|
+
warning: credential.warning,
|
|
6233
|
+
...credential.pointerVaultKey !== undefined ? { pointerVaultKey: credential.pointerVaultKey } : {}
|
|
5374
6234
|
});
|
|
5375
6235
|
}
|
|
5376
|
-
function
|
|
6236
|
+
function firstEnvValue2(env, keys) {
|
|
5377
6237
|
for (const key of keys) {
|
|
6238
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
6239
|
+
continue;
|
|
5378
6240
|
const value = env[key]?.trim();
|
|
5379
6241
|
if (value)
|
|
5380
6242
|
return { key, value };
|
|
5381
6243
|
}
|
|
5382
|
-
return null;
|
|
5383
|
-
}
|
|
5384
|
-
var
|
|
5385
|
-
function
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
}
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
6244
|
+
return null;
|
|
6245
|
+
}
|
|
6246
|
+
var AMBIENT_ENVIRONMENT2 = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
6247
|
+
function isAmbientEnvironment2(env) {
|
|
6248
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT2] === true;
|
|
6249
|
+
}
|
|
6250
|
+
function defaultKeychainRunner2(argv) {
|
|
6251
|
+
const result = spawnSync3(KEYCHAIN_SECURITY_BIN2, [...argv], {
|
|
6252
|
+
encoding: "utf8",
|
|
6253
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
6254
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS2
|
|
6255
|
+
});
|
|
6256
|
+
return {
|
|
6257
|
+
status: result.status,
|
|
6258
|
+
stdout: result.stdout ?? "",
|
|
6259
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
6260
|
+
};
|
|
6261
|
+
}
|
|
6262
|
+
function keychainTierEnabled2(env, options) {
|
|
6263
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
6264
|
+
return false;
|
|
6265
|
+
if (options.enabled !== undefined)
|
|
6266
|
+
return options.enabled;
|
|
6267
|
+
return options.run !== undefined || isAmbientEnvironment2(env);
|
|
6268
|
+
}
|
|
6269
|
+
function keychainAccount2(env, options) {
|
|
6270
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY2]?.trim();
|
|
6271
|
+
if (station)
|
|
6272
|
+
return station;
|
|
6273
|
+
const host = (options.hostname ?? osHostname2)().split(".")[0]?.trim() ?? "";
|
|
6274
|
+
if (host)
|
|
6275
|
+
return host;
|
|
6276
|
+
const user = env.USER?.trim();
|
|
6277
|
+
return user || null;
|
|
6278
|
+
}
|
|
6279
|
+
function keychainFailureHint2(text) {
|
|
6280
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
6281
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
6282
|
+
return clean ? `: ${clean}` : "";
|
|
6283
|
+
}
|
|
6284
|
+
function readKeychainItem2(name, env, kind, options) {
|
|
6285
|
+
if (!SAFE_APP_SLUG2.test(name) || !keychainTierEnabled2(env, options))
|
|
6286
|
+
return null;
|
|
6287
|
+
const account = keychainAccount2(env, options);
|
|
6288
|
+
if (!account)
|
|
6289
|
+
return null;
|
|
6290
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX2}.${name}.${kind}`;
|
|
6291
|
+
const source = `keychain:${service}@${account}`;
|
|
6292
|
+
const run = options.run ?? defaultKeychainRunner2;
|
|
6293
|
+
let result;
|
|
6294
|
+
try {
|
|
6295
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
6296
|
+
} catch (error) {
|
|
6297
|
+
const reason = keychainFailureHint2(error instanceof Error ? error.message : String(error));
|
|
6298
|
+
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]);
|
|
6299
|
+
}
|
|
6300
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS2)
|
|
6301
|
+
return null;
|
|
6302
|
+
if (result.status !== 0) {
|
|
6303
|
+
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]);
|
|
6304
|
+
}
|
|
6305
|
+
const value = result.stdout.trim();
|
|
6306
|
+
if (!value) {
|
|
6307
|
+
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]);
|
|
6308
|
+
}
|
|
6309
|
+
return { value, source };
|
|
6310
|
+
}
|
|
6311
|
+
function keychainConfigValue2(name, env, options = {}) {
|
|
6312
|
+
return readKeychainItem2(name, env, "api-url", options);
|
|
6313
|
+
}
|
|
6314
|
+
function snapshotClientEnvironment2(name, env) {
|
|
6315
|
+
const keys = clientTransportEnvKeys2(name);
|
|
6316
|
+
const ambient = isAmbientEnvironment2(env);
|
|
6317
|
+
const snapshot = Object.create(null);
|
|
6318
|
+
for (const key of [
|
|
6319
|
+
...keys.apiUrlKeys,
|
|
6320
|
+
...keys.apiKeyKeys,
|
|
6321
|
+
credentialOverrideEnvKey2(name),
|
|
6322
|
+
credentialPointerEnvKey2(name),
|
|
6323
|
+
CREDENTIAL_PROFILE_ENV_KEY2,
|
|
6324
|
+
"HOME",
|
|
6325
|
+
HASNA_HOME_ENV_KEY2,
|
|
6326
|
+
HASNA_CONFIG_HOME_ENV_KEY2,
|
|
6327
|
+
KEYCHAIN_STATION_ENV_KEY2,
|
|
6328
|
+
"USER"
|
|
6329
|
+
]) {
|
|
6330
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
6331
|
+
if (!descriptor)
|
|
6332
|
+
continue;
|
|
6333
|
+
if (!("value" in descriptor)) {
|
|
6334
|
+
throw new CredentialResolutionError2(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
6335
|
+
}
|
|
6336
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
6337
|
+
throw new CredentialResolutionError2(name, `${key} must be a string data property.`, [key]);
|
|
6338
|
+
}
|
|
6339
|
+
snapshot[key] = descriptor.value;
|
|
6340
|
+
}
|
|
6341
|
+
if (ambient) {
|
|
6342
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT2, {
|
|
6343
|
+
value: true,
|
|
6344
|
+
enumerable: false,
|
|
6345
|
+
writable: false,
|
|
6346
|
+
configurable: false
|
|
6347
|
+
});
|
|
5398
6348
|
}
|
|
6349
|
+
return Object.freeze(snapshot);
|
|
5399
6350
|
}
|
|
5400
|
-
function
|
|
5401
|
-
|
|
5402
|
-
const
|
|
5403
|
-
const
|
|
5404
|
-
if (
|
|
5405
|
-
|
|
5406
|
-
|
|
6351
|
+
function resolveCredential2(name, env, options = {}) {
|
|
6352
|
+
env = snapshotClientEnvironment2(name, env);
|
|
6353
|
+
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
6354
|
+
const diskPaths = credentialDiskSources2(name, env);
|
|
6355
|
+
if (options.apiKey !== undefined) {
|
|
6356
|
+
const explicitKey = options.apiKey.trim();
|
|
6357
|
+
if (!explicitKey) {
|
|
6358
|
+
throw new CredentialResolutionError2(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
6359
|
+
}
|
|
6360
|
+
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
6361
|
+
return sealCredential2({
|
|
5407
6362
|
apiKey: explicitKey,
|
|
5408
6363
|
tier: "argument",
|
|
5409
6364
|
source: "explicit apiKey argument",
|
|
5410
6365
|
deliberate: true,
|
|
5411
|
-
deprecated: false,
|
|
5412
6366
|
diskCandidates: diskPaths,
|
|
5413
6367
|
warning: null
|
|
5414
6368
|
});
|
|
5415
6369
|
}
|
|
5416
|
-
const overrideKeyName =
|
|
5417
|
-
const overrideRaw = env[overrideKeyName];
|
|
6370
|
+
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
6371
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
5418
6372
|
if (overrideRaw !== undefined) {
|
|
5419
6373
|
const override = overrideRaw.trim();
|
|
5420
6374
|
if (!override) {
|
|
5421
|
-
throw new
|
|
6375
|
+
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
6376
|
}
|
|
5423
|
-
|
|
5424
|
-
return
|
|
6377
|
+
assertUsableCredential2(name, overrideKeyName, override);
|
|
6378
|
+
return sealCredential2({
|
|
5425
6379
|
apiKey: override,
|
|
5426
6380
|
tier: "override",
|
|
5427
6381
|
source: overrideKeyName,
|
|
5428
6382
|
deliberate: true,
|
|
5429
|
-
deprecated: false,
|
|
5430
6383
|
diskCandidates: diskPaths,
|
|
5431
6384
|
warning: null
|
|
5432
6385
|
});
|
|
5433
6386
|
}
|
|
5434
|
-
const
|
|
6387
|
+
const pointerKeyName = credentialPointerEnvKey2(name);
|
|
6388
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
6389
|
+
if (pointerRaw !== undefined) {
|
|
6390
|
+
const pointer = pointerRaw.trim();
|
|
6391
|
+
if (!pointer) {
|
|
6392
|
+
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]);
|
|
6393
|
+
}
|
|
6394
|
+
if (!VAULT_POINTER_SHAPE2.test(pointer)) {
|
|
6395
|
+
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]);
|
|
6396
|
+
}
|
|
6397
|
+
return sealCredential2({
|
|
6398
|
+
apiKey: "",
|
|
6399
|
+
pointerVaultKey: pointer,
|
|
6400
|
+
tier: "pointer",
|
|
6401
|
+
source: pointerKeyName,
|
|
6402
|
+
deliberate: true,
|
|
6403
|
+
diskCandidates: diskPaths,
|
|
6404
|
+
warning: null
|
|
6405
|
+
});
|
|
6406
|
+
}
|
|
6407
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
6408
|
+
throw new CredentialResolutionError2(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
6409
|
+
}
|
|
6410
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY2) ? env[CREDENTIAL_PROFILE_ENV_KEY2] : undefined;
|
|
6411
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
6412
|
+
throw new CredentialResolutionError2(name, `${CREDENTIAL_PROFILE_ENV_KEY2} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY2]);
|
|
6413
|
+
}
|
|
6414
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
5435
6415
|
if (profile) {
|
|
5436
|
-
const profileSource = options.profile?.trim() ? "explicit profile argument" :
|
|
5437
|
-
if (!
|
|
5438
|
-
throw new
|
|
6416
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
6417
|
+
if (!SAFE_PROFILE2.test(profile)) {
|
|
6418
|
+
throw new CredentialResolutionError2(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
5439
6419
|
}
|
|
5440
|
-
const paths =
|
|
6420
|
+
const paths = profileDiskSources2(name, env, profile);
|
|
5441
6421
|
for (const path of paths) {
|
|
5442
|
-
const value =
|
|
6422
|
+
const value = readCredentialFile2(path, apiKeyKeys);
|
|
5443
6423
|
if (value) {
|
|
5444
|
-
|
|
5445
|
-
return
|
|
6424
|
+
assertUsableCredential2(name, path, value);
|
|
6425
|
+
return sealCredential2({
|
|
5446
6426
|
apiKey: value,
|
|
5447
6427
|
tier: "profile",
|
|
5448
6428
|
source: path,
|
|
5449
6429
|
deliberate: true,
|
|
5450
|
-
deprecated: false,
|
|
5451
6430
|
diskCandidates: paths,
|
|
5452
6431
|
warning: null
|
|
5453
6432
|
});
|
|
5454
6433
|
}
|
|
5455
6434
|
}
|
|
5456
|
-
throw new
|
|
6435
|
+
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);
|
|
6436
|
+
}
|
|
6437
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
6438
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
6439
|
+
if (blankEnv) {
|
|
6440
|
+
throw new CredentialResolutionError2(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
6441
|
+
}
|
|
6442
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
6443
|
+
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));
|
|
6444
|
+
}
|
|
6445
|
+
const envHit = firstEnvValue2(env, apiKeyKeys);
|
|
6446
|
+
const keychainHit = readKeychainItem2(name, env, "api-key", options.keychain ?? {});
|
|
6447
|
+
if (keychainHit) {
|
|
6448
|
+
assertUsableCredential2(name, keychainHit.source, keychainHit.value);
|
|
6449
|
+
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;
|
|
6450
|
+
return sealCredential2({
|
|
6451
|
+
apiKey: keychainHit.value,
|
|
6452
|
+
tier: "keychain",
|
|
6453
|
+
source: keychainHit.source,
|
|
6454
|
+
deliberate: false,
|
|
6455
|
+
diskCandidates: diskPaths,
|
|
6456
|
+
warning
|
|
6457
|
+
});
|
|
5457
6458
|
}
|
|
5458
|
-
const
|
|
6459
|
+
const diskSourceList = credentialDiskSourceList2(name, env, null);
|
|
6460
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile2(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
5459
6461
|
if (diskHits.length > 0) {
|
|
5460
6462
|
const winner = diskHits[0];
|
|
5461
|
-
|
|
6463
|
+
assertUsableCredential2(name, winner.src.path, winner.value);
|
|
5462
6464
|
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
|
-
})()
|
|
6465
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
6466
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
5468
6467
|
];
|
|
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
|
|
6468
|
+
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;
|
|
6469
|
+
return sealCredential2({
|
|
5471
6470
|
apiKey: winner.value,
|
|
5472
|
-
tier:
|
|
5473
|
-
source: winner.path,
|
|
6471
|
+
tier: winner.src.tier,
|
|
6472
|
+
source: winner.src.path,
|
|
5474
6473
|
deliberate: false,
|
|
5475
|
-
deprecated: false,
|
|
5476
6474
|
diskCandidates: diskPaths,
|
|
5477
6475
|
warning
|
|
5478
6476
|
});
|
|
5479
6477
|
}
|
|
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,
|
|
6478
|
+
if (envHit) {
|
|
6479
|
+
assertUsableCredential2(name, envHit.key, envHit.value);
|
|
6480
|
+
return sealCredential2({
|
|
6481
|
+
apiKey: envHit.value,
|
|
6482
|
+
tier: "env",
|
|
6483
|
+
source: envHit.key,
|
|
5495
6484
|
deliberate: false,
|
|
5496
|
-
deprecated: true,
|
|
5497
6485
|
diskCandidates: diskPaths,
|
|
5498
|
-
warning:
|
|
6486
|
+
warning: null
|
|
5499
6487
|
});
|
|
5500
6488
|
}
|
|
5501
6489
|
return null;
|
|
5502
6490
|
}
|
|
5503
|
-
var
|
|
5504
|
-
var
|
|
5505
|
-
function
|
|
5506
|
-
|
|
5507
|
-
|
|
6491
|
+
var SECRETS_PACKAGE_SPECIFIER2 = "@hasna/" + "secrets";
|
|
6492
|
+
var requireSecretsSdk2 = createRequire2(import.meta.url);
|
|
6493
|
+
async function completePointerCredential(name, pointerResolution, env = process.env) {
|
|
6494
|
+
const vaultKey = pointerResolution.pointerVaultKey;
|
|
6495
|
+
const pointerEnvKey = pointerResolution.source;
|
|
6496
|
+
if (!vaultKey) {
|
|
6497
|
+
throw new CredentialResolutionError2(name, `Pointer resolution from ${pointerEnvKey} carries no vault item key; this is a defect in the resolver.`, [pointerEnvKey]);
|
|
5508
6498
|
}
|
|
5509
|
-
|
|
6499
|
+
let secretsSdk;
|
|
6500
|
+
try {
|
|
6501
|
+
secretsSdk = requireSecretsSdk2(SECRETS_PACKAGE_SPECIFIER2);
|
|
6502
|
+
} catch {
|
|
6503
|
+
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]);
|
|
6504
|
+
}
|
|
6505
|
+
let client;
|
|
6506
|
+
try {
|
|
6507
|
+
client = secretsSdk.createSecretsClientFromEnv(env);
|
|
6508
|
+
} catch {
|
|
6509
|
+
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]);
|
|
6510
|
+
}
|
|
6511
|
+
let secret;
|
|
6512
|
+
try {
|
|
6513
|
+
secret = await client.getSecret({ key: vaultKey });
|
|
6514
|
+
} catch {
|
|
6515
|
+
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]);
|
|
6516
|
+
}
|
|
6517
|
+
const value = secret.value;
|
|
6518
|
+
if (!value) {
|
|
6519
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} resolved vault item '${vaultKey}', but it holds no value. A vault pointer is TERMINAL.`, [pointerEnvKey]);
|
|
6520
|
+
}
|
|
6521
|
+
assertUsableCredential2(name, `${pointerEnvKey} -> vault:${vaultKey}`, value);
|
|
6522
|
+
return sealCredential2({
|
|
6523
|
+
apiKey: value,
|
|
6524
|
+
tier: "pointer",
|
|
6525
|
+
source: `${pointerEnvKey} -> vault:${vaultKey}`,
|
|
6526
|
+
deliberate: true,
|
|
6527
|
+
diskCandidates: pointerResolution.diskCandidates,
|
|
6528
|
+
warning: null
|
|
6529
|
+
});
|
|
5510
6530
|
}
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
6531
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN2 = "https://api.hasna.com";
|
|
6532
|
+
var DEFAULT_AUTHORITY_SOURCE2 = "default";
|
|
6533
|
+
function defaultFleetGatewayBaseUrl2(name) {
|
|
6534
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN2}/${validateAppSlug2(name)}`;
|
|
6535
|
+
}
|
|
6536
|
+
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
6537
|
+
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
6538
|
+
function isValidDnsDomain2(value) {
|
|
6539
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN2.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
6540
|
+
return false;
|
|
5517
6541
|
}
|
|
5518
|
-
return
|
|
6542
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
5519
6543
|
}
|
|
5520
|
-
function
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
return key;
|
|
6544
|
+
function validateAppSlug2(name) {
|
|
6545
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN2.test(name)) {
|
|
6546
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
5524
6547
|
}
|
|
5525
|
-
return
|
|
6548
|
+
return name;
|
|
5526
6549
|
}
|
|
5527
|
-
function
|
|
6550
|
+
function rawAuthority2(value) {
|
|
5528
6551
|
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
5529
6552
|
if (!match)
|
|
5530
6553
|
throw new Error("API URL must be absolute.");
|
|
@@ -5535,7 +6558,7 @@ function rawAuthority(value) {
|
|
|
5535
6558
|
throw new Error("API URL must include a hostname.");
|
|
5536
6559
|
return authority;
|
|
5537
6560
|
}
|
|
5538
|
-
function
|
|
6561
|
+
function assertCanonicalPort2(port) {
|
|
5539
6562
|
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
5540
6563
|
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
5541
6564
|
}
|
|
@@ -5544,7 +6567,7 @@ function assertCanonicalPort(port) {
|
|
|
5544
6567
|
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
5545
6568
|
}
|
|
5546
6569
|
}
|
|
5547
|
-
function
|
|
6570
|
+
function canonicalAuthorityHostname2(authority) {
|
|
5548
6571
|
let rawHostname;
|
|
5549
6572
|
if (authority.startsWith("[")) {
|
|
5550
6573
|
const closingBracket = authority.indexOf("]");
|
|
@@ -5557,9 +6580,9 @@ function canonicalAuthorityHostname(authority) {
|
|
|
5557
6580
|
if (!portSuffix.startsWith(":")) {
|
|
5558
6581
|
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
5559
6582
|
}
|
|
5560
|
-
|
|
6583
|
+
assertCanonicalPort2(portSuffix.slice(1));
|
|
5561
6584
|
}
|
|
5562
|
-
if (
|
|
6585
|
+
if (isIP2(rawHostname.slice(1, -1)) !== 6) {
|
|
5563
6586
|
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
5564
6587
|
}
|
|
5565
6588
|
} else {
|
|
@@ -5570,33 +6593,33 @@ function canonicalAuthorityHostname(authority) {
|
|
|
5570
6593
|
}
|
|
5571
6594
|
if (lastColon !== -1) {
|
|
5572
6595
|
const port = authority.slice(lastColon + 1);
|
|
5573
|
-
|
|
6596
|
+
assertCanonicalPort2(port);
|
|
5574
6597
|
rawHostname = authority.slice(0, lastColon);
|
|
5575
6598
|
} else {
|
|
5576
6599
|
rawHostname = authority;
|
|
5577
6600
|
}
|
|
5578
|
-
const ipVersion =
|
|
6601
|
+
const ipVersion = isIP2(rawHostname);
|
|
5579
6602
|
const numericAddressParts = rawHostname.split(".");
|
|
5580
6603
|
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
5581
|
-
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !
|
|
6604
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain2(rawHostname.toLowerCase())) {
|
|
5582
6605
|
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
5583
6606
|
}
|
|
5584
6607
|
}
|
|
5585
6608
|
return rawHostname.toLowerCase();
|
|
5586
6609
|
}
|
|
5587
|
-
function
|
|
6610
|
+
function isDeliberateLoopbackHttpAuthority2(authority) {
|
|
5588
6611
|
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
5589
6612
|
}
|
|
5590
|
-
function
|
|
5591
|
-
if (
|
|
6613
|
+
function toV1BaseUrl2(apiUrl) {
|
|
6614
|
+
if (ASCII_CONTROL_PATTERN2.test(apiUrl)) {
|
|
5592
6615
|
throw new Error("API URL must not contain ASCII control characters.");
|
|
5593
6616
|
}
|
|
5594
6617
|
const input = apiUrl.trim();
|
|
5595
|
-
const authority =
|
|
6618
|
+
const authority = rawAuthority2(input);
|
|
5596
6619
|
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
5597
6620
|
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
5598
6621
|
}
|
|
5599
|
-
const canonicalHostname =
|
|
6622
|
+
const canonicalHostname = canonicalAuthorityHostname2(authority);
|
|
5600
6623
|
const url = new URL(input);
|
|
5601
6624
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
5602
6625
|
throw new Error("API URL must use http or https.");
|
|
@@ -5613,7 +6636,7 @@ function toV1BaseUrl(apiUrl) {
|
|
|
5613
6636
|
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
5614
6637
|
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
5615
6638
|
}
|
|
5616
|
-
if (url.protocol === "http:" && !
|
|
6639
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority2(authority)) {
|
|
5617
6640
|
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
5618
6641
|
}
|
|
5619
6642
|
if (url.search || url.hash) {
|
|
@@ -5625,106 +6648,104 @@ function toV1BaseUrl(apiUrl) {
|
|
|
5625
6648
|
url.pathname = `${path}/v1`;
|
|
5626
6649
|
return url.toString().replace(/\/+$/, "");
|
|
5627
6650
|
}
|
|
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
|
-
|
|
6651
|
+
class ClientTransportConfigurationError2 extends Error {
|
|
6652
|
+
appName;
|
|
6653
|
+
sources;
|
|
6654
|
+
constructor(appName, message, sources = []) {
|
|
6655
|
+
super(message);
|
|
6656
|
+
this.name = "ClientTransportConfigurationError";
|
|
6657
|
+
this.appName = appName;
|
|
6658
|
+
this.sources = Object.freeze([...sources]);
|
|
6659
|
+
}
|
|
6660
|
+
}
|
|
6661
|
+
function resolveClientTransportSnapshot2(name, env = process.env, options = {}) {
|
|
6662
|
+
env = snapshotClientEnvironment2(name, env);
|
|
6663
|
+
const keys = clientTransportEnvKeys2(name);
|
|
6664
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
6665
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
6666
|
+
if (blankUrl) {
|
|
6667
|
+
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]);
|
|
6668
|
+
}
|
|
6669
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN2.test(entry.raw));
|
|
6670
|
+
if (controlledUrl) {
|
|
6671
|
+
throw new ClientTransportConfigurationError2(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
6672
|
+
}
|
|
6673
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
6674
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
6675
|
+
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));
|
|
6676
|
+
}
|
|
6677
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
6678
|
+
const keychainUrlHit = keychainConfigValue2(name, env, options.credentials?.keychain);
|
|
6679
|
+
const diskConfigUrlHit = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
6680
|
+
if (diskConfigUrlHit?.unusable) {
|
|
6681
|
+
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]);
|
|
6682
|
+
}
|
|
6683
|
+
const urlCandidates = [
|
|
6684
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
6685
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
6686
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
6687
|
+
];
|
|
6688
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
6689
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
6690
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
6691
|
+
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
6692
|
}
|
|
5666
|
-
|
|
5667
|
-
|
|
6693
|
+
const warnings = [];
|
|
6694
|
+
if (configuredUrl && !envUrlHit) {
|
|
6695
|
+
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
6696
|
}
|
|
5669
|
-
const credential =
|
|
6697
|
+
const credential = resolveCredential2(name, env, options.credentials);
|
|
5670
6698
|
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
|
-
};
|
|
6699
|
+
const diskHint = credentialDiskSourcesForMessage2(name, env);
|
|
6700
|
+
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`;
|
|
6701
|
+
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.`);
|
|
6702
|
+
throw new ClientTransportConfigurationError2(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
5684
6703
|
}
|
|
5685
6704
|
if (credential.warning)
|
|
5686
6705
|
warnings.push(credential.warning);
|
|
6706
|
+
let urlHit;
|
|
6707
|
+
if (configuredUrl) {
|
|
6708
|
+
urlHit = configuredUrl;
|
|
6709
|
+
} else {
|
|
6710
|
+
try {
|
|
6711
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE2, value: defaultFleetGatewayBaseUrl2(name) };
|
|
6712
|
+
} catch (error) {
|
|
6713
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6714
|
+
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]]);
|
|
6715
|
+
}
|
|
6716
|
+
}
|
|
5687
6717
|
const apiUrlSource = urlHit.key;
|
|
5688
6718
|
let baseUrl;
|
|
5689
6719
|
try {
|
|
5690
|
-
baseUrl =
|
|
6720
|
+
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
5691
6721
|
} catch (error) {
|
|
5692
6722
|
const message = error instanceof Error ? error.message : String(error);
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
6723
|
+
throw new ClientTransportConfigurationError2(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
6724
|
+
}
|
|
6725
|
+
return {
|
|
6726
|
+
resolution: {
|
|
6727
|
+
transport: "http",
|
|
5696
6728
|
transportSource: urlHit.key,
|
|
5697
|
-
baseUrl
|
|
5698
|
-
apiUrlSource
|
|
6729
|
+
baseUrl,
|
|
6730
|
+
apiUrlSource,
|
|
5699
6731
|
apiKeyPresent: true,
|
|
5700
6732
|
apiKeySource: credential.source,
|
|
5701
6733
|
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
|
|
6734
|
+
misconfigured: false,
|
|
6735
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
6736
|
+
},
|
|
6737
|
+
credential
|
|
5716
6738
|
};
|
|
5717
6739
|
}
|
|
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>";
|
|
6740
|
+
function credentialDiskSourcesForMessage2(name, env) {
|
|
6741
|
+
const paths = credentialDiskSources2(name, env);
|
|
6742
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
5721
6743
|
}
|
|
5722
6744
|
|
|
5723
6745
|
class HasnaHttpError extends Error {
|
|
5724
6746
|
status;
|
|
5725
6747
|
method;
|
|
5726
6748
|
path;
|
|
5727
|
-
body;
|
|
5728
6749
|
credentialSource;
|
|
5729
6750
|
credentialTier;
|
|
5730
6751
|
constructor(method, path, status, body, credential) {
|
|
@@ -5734,7 +6755,12 @@ class HasnaHttpError extends Error {
|
|
|
5734
6755
|
this.status = status;
|
|
5735
6756
|
this.method = method;
|
|
5736
6757
|
this.path = path;
|
|
5737
|
-
this
|
|
6758
|
+
Object.defineProperty(this, "body", {
|
|
6759
|
+
value: body,
|
|
6760
|
+
enumerable: status !== 401 && status !== 403,
|
|
6761
|
+
writable: false,
|
|
6762
|
+
configurable: false
|
|
6763
|
+
});
|
|
5738
6764
|
this.credentialSource = credential?.source ?? null;
|
|
5739
6765
|
this.credentialTier = credential?.tier ?? null;
|
|
5740
6766
|
}
|
|
@@ -5745,22 +6771,32 @@ function currentCredential(name, apiKey) {
|
|
|
5745
6771
|
}
|
|
5746
6772
|
return explicitCredential(name, apiKey);
|
|
5747
6773
|
}
|
|
6774
|
+
async function resolveRequestCredential(name, apiKey, env = process.env) {
|
|
6775
|
+
const resolved = currentCredential(name, apiKey);
|
|
6776
|
+
if (resolved.tier === "pointer") {
|
|
6777
|
+
return completePointerCredential(name, resolved, env);
|
|
6778
|
+
}
|
|
6779
|
+
return resolved;
|
|
6780
|
+
}
|
|
5748
6781
|
function authFailureGuidance(credential) {
|
|
5749
6782
|
const origin = `The API key for this request came from ${credential.source}`;
|
|
5750
6783
|
if (credential.deliberate) {
|
|
5751
6784
|
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
6785
|
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
6786
|
}
|
|
5754
|
-
if (credential.
|
|
6787
|
+
if (credential.tier === "env") {
|
|
5755
6788
|
const target = credential.diskCandidates[0];
|
|
5756
|
-
const remedy = target ? `
|
|
5757
|
-
return `${origin}, a variable in this process's environment
|
|
6789
|
+
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.`;
|
|
6790
|
+
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}`;
|
|
6791
|
+
}
|
|
6792
|
+
if (credential.tier === "keychain") {
|
|
6793
|
+
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
6794
|
}
|
|
5759
6795
|
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
6796
|
}
|
|
5761
6797
|
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
5762
|
-
var
|
|
5763
|
-
var
|
|
6798
|
+
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
6799
|
+
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
5764
6800
|
"host",
|
|
5765
6801
|
":authority",
|
|
5766
6802
|
"forwarded",
|
|
@@ -5770,7 +6806,7 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
|
5770
6806
|
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
5771
6807
|
if (!headers)
|
|
5772
6808
|
return;
|
|
5773
|
-
const forbidden = Object.keys(headers).find((name) =>
|
|
6809
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
5774
6810
|
if (forbidden) {
|
|
5775
6811
|
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
5776
6812
|
}
|
|
@@ -5797,9 +6833,9 @@ function appendQuery(path, query) {
|
|
|
5797
6833
|
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
5798
6834
|
}
|
|
5799
6835
|
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
5800
|
-
function
|
|
6836
|
+
function createHasnaHttpTransportInternal(options, requestBindingProvider) {
|
|
5801
6837
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
5802
|
-
const base =
|
|
6838
|
+
const base = toV1BaseUrl2(options.baseUrl);
|
|
5803
6839
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
5804
6840
|
const sleep = options.sleepImpl ?? defaultSleep;
|
|
5805
6841
|
const defaultRetry = options.retry;
|
|
@@ -5859,13 +6895,20 @@ function createHasnaHttpTransport(options) {
|
|
|
5859
6895
|
if (opts.signal)
|
|
5860
6896
|
opts.signal.removeEventListener("abort", onAbort);
|
|
5861
6897
|
}
|
|
5862
|
-
const
|
|
6898
|
+
const authenticationFailure = response.status === 401 || response.status === 403;
|
|
5863
6899
|
let parsed = undefined;
|
|
5864
|
-
if (
|
|
6900
|
+
if (authenticationFailure) {
|
|
5865
6901
|
try {
|
|
5866
|
-
|
|
5867
|
-
} catch {
|
|
5868
|
-
|
|
6902
|
+
await response.body?.cancel();
|
|
6903
|
+
} catch {}
|
|
6904
|
+
} else {
|
|
6905
|
+
const text = await response.text();
|
|
6906
|
+
if (text.length > 0) {
|
|
6907
|
+
try {
|
|
6908
|
+
parsed = JSON.parse(text);
|
|
6909
|
+
} catch {
|
|
6910
|
+
parsed = text;
|
|
6911
|
+
}
|
|
5869
6912
|
}
|
|
5870
6913
|
}
|
|
5871
6914
|
if (!response.ok) {
|
|
@@ -5876,11 +6919,11 @@ function createHasnaHttpTransport(options) {
|
|
|
5876
6919
|
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
5877
6920
|
};
|
|
5878
6921
|
}
|
|
5879
|
-
if (
|
|
6922
|
+
if (authenticationFailure) {
|
|
5880
6923
|
return {
|
|
5881
6924
|
ok: false,
|
|
5882
6925
|
retryable: false,
|
|
5883
|
-
error: new HasnaHttpError(method, rel, response.status,
|
|
6926
|
+
error: new HasnaHttpError(method, rel, response.status, undefined, {
|
|
5884
6927
|
source: credential.source,
|
|
5885
6928
|
tier: credential.tier,
|
|
5886
6929
|
guidance: authFailureGuidance(credential)
|
|
@@ -5896,11 +6939,15 @@ function createHasnaHttpTransport(options) {
|
|
|
5896
6939
|
async function request(method, path, body, opts = {}) {
|
|
5897
6940
|
const upper = method.toUpperCase();
|
|
5898
6941
|
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
5899
|
-
const url = `${base}${rel}`;
|
|
5900
6942
|
const retry = resolveRetry(opts.retry);
|
|
5901
|
-
const methodRetryable =
|
|
6943
|
+
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
5902
6944
|
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
5903
|
-
const
|
|
6945
|
+
const binding = requestBindingProvider ? await requestBindingProvider() : {
|
|
6946
|
+
baseUrl: base,
|
|
6947
|
+
credential: await resolveRequestCredential(options.name, options.apiKey)
|
|
6948
|
+
};
|
|
6949
|
+
const url = `${binding.baseUrl}${rel}`;
|
|
6950
|
+
const credential = binding.credential;
|
|
5904
6951
|
let last = null;
|
|
5905
6952
|
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
5906
6953
|
const result = await once(upper, rel, url, body, opts, credential);
|
|
@@ -5928,49 +6975,44 @@ function createHasnaHttpTransport(options) {
|
|
|
5928
6975
|
}
|
|
5929
6976
|
function createClientTransport(name, env = process.env, overrides) {
|
|
5930
6977
|
const credentialOptions = overrides?.credentials;
|
|
5931
|
-
const
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
if (
|
|
5941
|
-
throw new
|
|
5942
|
-
}
|
|
5943
|
-
|
|
6978
|
+
const snapshotOptions = { ...credentialOptions ? { credentials: credentialOptions } : {} };
|
|
6979
|
+
const resolution = resolveClientTransportSnapshot2(name, env, snapshotOptions).resolution;
|
|
6980
|
+
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;
|
|
6981
|
+
const unstableConfiguration = () => new ClientTransportConfigurationError2(name, "The configured service authority or credential changed while a request was being prepared; no authenticated request was sent.");
|
|
6982
|
+
const requestBindingProvider = async () => {
|
|
6983
|
+
const first = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
6984
|
+
const reviewed = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
6985
|
+
if (!sameBinding(first, reviewed))
|
|
6986
|
+
throw unstableConfiguration();
|
|
6987
|
+
if (reviewed.resolution.baseUrl !== resolution.baseUrl) {
|
|
6988
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
6989
|
+
}
|
|
6990
|
+
const credential = await resolveRequestCredential(name, () => reviewed.credential, env);
|
|
6991
|
+
const immediatelyBeforeDispatch = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
6992
|
+
if (!sameBinding(reviewed, immediatelyBeforeDispatch))
|
|
6993
|
+
throw unstableConfiguration();
|
|
6994
|
+
if (immediatelyBeforeDispatch.resolution.baseUrl !== resolution.baseUrl) {
|
|
6995
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
6996
|
+
}
|
|
6997
|
+
return { baseUrl: immediatelyBeforeDispatch.resolution.baseUrl, credential };
|
|
5944
6998
|
};
|
|
5945
6999
|
return {
|
|
5946
7000
|
transport: "http",
|
|
5947
|
-
client:
|
|
7001
|
+
client: createHasnaHttpTransportInternal({
|
|
5948
7002
|
name,
|
|
5949
7003
|
baseUrl: resolution.baseUrl,
|
|
5950
|
-
apiKey:
|
|
7004
|
+
apiKey: () => {
|
|
7005
|
+
throw new Error("The authenticated request binding provider was not invoked.");
|
|
7006
|
+
},
|
|
5951
7007
|
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
5952
7008
|
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
5953
7009
|
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
5954
7010
|
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
5955
7011
|
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
5956
|
-
}),
|
|
7012
|
+
}, requestBindingProvider),
|
|
5957
7013
|
resolution
|
|
5958
7014
|
};
|
|
5959
7015
|
}
|
|
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
7016
|
function resourcePath(resource) {
|
|
5975
7017
|
const trimmed = resource.replace(/^\/+|\/+$/g, "");
|
|
5976
7018
|
if (!trimmed)
|
|
@@ -6070,145 +7112,336 @@ function createHasnaStorageClient(name, transport) {
|
|
|
6070
7112
|
}
|
|
6071
7113
|
};
|
|
6072
7114
|
}
|
|
7115
|
+
function resolveStorageClient(name, env = process.env, overrides) {
|
|
7116
|
+
const wired = createClientTransport(name, env, overrides);
|
|
7117
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
7118
|
+
}
|
|
7119
|
+
|
|
7120
|
+
// src/lib/local-opt-in.ts
|
|
7121
|
+
var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
|
|
7122
|
+
"HASNA_RECORDINGS_LOCAL",
|
|
7123
|
+
"RECORDINGS_LOCAL"
|
|
7124
|
+
];
|
|
7125
|
+
function isRecordingsLocalOptIn(env = process.env) {
|
|
7126
|
+
return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
7127
|
+
}
|
|
7128
|
+
var RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
|
|
7129
|
+
"RECORDINGS_API_URL",
|
|
7130
|
+
"RECORDINGS_API_KEY"
|
|
7131
|
+
];
|
|
7132
|
+
function recordingsAuthorityEnvKeys() {
|
|
7133
|
+
const keys = clientTransportEnvKeys("recordings");
|
|
7134
|
+
return [
|
|
7135
|
+
...keys.apiUrlKeys,
|
|
7136
|
+
...keys.apiKeyKeys,
|
|
7137
|
+
credentialOverrideEnvKey("recordings"),
|
|
7138
|
+
credentialPointerEnvKey("recordings"),
|
|
7139
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
7140
|
+
].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
|
|
7141
|
+
}
|
|
7142
|
+
function hasRecordingsEnvAuthorityIntent(env = process.env) {
|
|
7143
|
+
return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
7144
|
+
}
|
|
7145
|
+
function selectsRecordingsLocalStore(env = process.env) {
|
|
7146
|
+
return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
|
|
7147
|
+
}
|
|
7148
|
+
function recordingsResolverEnv(env) {
|
|
7149
|
+
const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
|
|
7150
|
+
const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
7151
|
+
const removed = new Set([...carved, ...blanks]);
|
|
7152
|
+
if (removed.size === 0)
|
|
7153
|
+
return env;
|
|
7154
|
+
const next = { ...env };
|
|
7155
|
+
for (const key of removed)
|
|
7156
|
+
delete next[key];
|
|
7157
|
+
return next;
|
|
7158
|
+
}
|
|
7159
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
7160
|
+
function isAmbientRecordingsEnv(env) {
|
|
7161
|
+
if (typeof process !== "undefined" && env === process.env) {
|
|
7162
|
+
return true;
|
|
7163
|
+
}
|
|
7164
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
7165
|
+
}
|
|
7166
|
+
function recordingsResolverInputs(env, credentials = {}) {
|
|
7167
|
+
const normalised = recordingsResolverEnv(env);
|
|
7168
|
+
if (normalised === env)
|
|
7169
|
+
return { env: normalised, credentials };
|
|
7170
|
+
const keychain = { ...credentials.keychain };
|
|
7171
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
7172
|
+
keychain.enabled = isAmbientRecordingsEnv(env);
|
|
7173
|
+
}
|
|
7174
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
7175
|
+
}
|
|
6073
7176
|
|
|
6074
7177
|
// src/http/client.ts
|
|
6075
|
-
|
|
6076
|
-
|
|
7178
|
+
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
7179
|
+
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
7180
|
+
function throwRecordingsAuthorityFailure(error) {
|
|
7181
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7182
|
+
const name = error instanceof Error ? error.name : "";
|
|
7183
|
+
if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
|
|
7184
|
+
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 });
|
|
7185
|
+
}
|
|
7186
|
+
if (/no API key could be resolved/.test(message)) {
|
|
7187
|
+
if (/is not set and no API key could be resolved/.test(message)) {
|
|
7188
|
+
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 });
|
|
7189
|
+
}
|
|
7190
|
+
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 });
|
|
7191
|
+
}
|
|
7192
|
+
throw new Error(`REMOTE_API_URL_INVALID: ${message} local SQLite fallback is disabled`, { cause: error });
|
|
6077
7193
|
}
|
|
6078
|
-
function
|
|
6079
|
-
const token = envToken2(name);
|
|
7194
|
+
function toRecordsAuthority(resolution) {
|
|
6080
7195
|
return {
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
7196
|
+
transport: resolution.transport,
|
|
7197
|
+
transportSource: resolution.transportSource,
|
|
7198
|
+
baseUrl: resolution.baseUrl,
|
|
7199
|
+
apiUrlSource: resolution.apiUrlSource,
|
|
7200
|
+
apiKeyPresent: resolution.apiKeyPresent,
|
|
7201
|
+
apiKeySource: resolution.apiKeySource,
|
|
7202
|
+
apiKeyTier: resolution.apiKeyTier,
|
|
7203
|
+
misconfigured: resolution.misconfigured,
|
|
7204
|
+
warning: resolution.warning
|
|
6084
7205
|
};
|
|
6085
7206
|
}
|
|
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 };
|
|
7207
|
+
function resolveRecordingsTransport(env = process.env, options = {}) {
|
|
7208
|
+
if (selectsRecordingsLocalStore(env)) {
|
|
7209
|
+
return { transport: "sqlite", selected: false, source: "local-opt-in", authority: null };
|
|
6099
7210
|
}
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
7211
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
7212
|
+
let resolution;
|
|
7213
|
+
try {
|
|
7214
|
+
resolution = resolveClientTransport("recordings", resolverInputs.env, {
|
|
7215
|
+
credentials: resolverInputs.credentials
|
|
7216
|
+
});
|
|
7217
|
+
} catch (error) {
|
|
7218
|
+
throwRecordingsAuthorityFailure(error);
|
|
6106
7219
|
}
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
return url.toString().replace(/\/+$/, "");
|
|
7220
|
+
return {
|
|
7221
|
+
transport: "http",
|
|
7222
|
+
selected: true,
|
|
7223
|
+
source: `${resolution.apiKeySource ?? resolution.apiKeyTier}+${resolution.apiUrlSource ?? "default"}`,
|
|
7224
|
+
authority: toRecordsAuthority(resolution)
|
|
7225
|
+
};
|
|
6114
7226
|
}
|
|
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];
|
|
7227
|
+
function getRecordingsTransportStatus(env = process.env, options = {}) {
|
|
7228
|
+
let resolution;
|
|
7229
|
+
try {
|
|
7230
|
+
resolution = resolveRecordingsTransport(env, options);
|
|
7231
|
+
} catch (error) {
|
|
7232
|
+
const issue = error instanceof Error ? error.message : String(error);
|
|
7233
|
+
const envKeys = clientTransportEnvKeys("recordings");
|
|
7234
|
+
const declared = (keys) => keys.some((key) => (env[key] ?? "").trim() !== "");
|
|
6131
7235
|
return {
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
7236
|
+
selected: true,
|
|
7237
|
+
ok: false,
|
|
7238
|
+
transport: "invalid",
|
|
7239
|
+
api_url_configured: declared(envKeys.apiUrlKeys),
|
|
7240
|
+
api_key_configured: declared(envKeys.apiKeyKeys),
|
|
7241
|
+
api_url_source: null,
|
|
7242
|
+
api_key_source: null,
|
|
7243
|
+
api_key_tier: null,
|
|
7244
|
+
v1_base_url: null,
|
|
7245
|
+
issues: [issue],
|
|
7246
|
+
local_fallback: false
|
|
6139
7247
|
};
|
|
6140
7248
|
}
|
|
6141
|
-
if (
|
|
6142
|
-
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
6143
|
-
}
|
|
6144
|
-
if (!urlHit) {
|
|
7249
|
+
if (!resolution.selected) {
|
|
6145
7250
|
return {
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
7251
|
+
selected: false,
|
|
7252
|
+
ok: true,
|
|
7253
|
+
transport: resolution.transport,
|
|
7254
|
+
api_url_configured: false,
|
|
7255
|
+
api_key_configured: false,
|
|
7256
|
+
api_url_source: null,
|
|
7257
|
+
api_key_source: null,
|
|
7258
|
+
api_key_tier: null,
|
|
7259
|
+
v1_base_url: null,
|
|
7260
|
+
issues: [],
|
|
7261
|
+
local_fallback: false
|
|
6153
7262
|
};
|
|
6154
7263
|
}
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
7264
|
+
const authority = resolution.authority;
|
|
7265
|
+
return {
|
|
7266
|
+
selected: true,
|
|
7267
|
+
ok: true,
|
|
7268
|
+
transport: resolution.transport,
|
|
7269
|
+
api_url_configured: authority.apiUrlSource !== null && authority.apiUrlSource !== "default",
|
|
7270
|
+
api_key_configured: authority.apiKeyPresent,
|
|
7271
|
+
api_url_source: authority.apiUrlSource,
|
|
7272
|
+
api_key_source: authority.apiKeySource,
|
|
7273
|
+
api_key_tier: authority.apiKeyTier,
|
|
7274
|
+
v1_base_url: authority.baseUrl,
|
|
7275
|
+
issues: [],
|
|
7276
|
+
local_fallback: false
|
|
7277
|
+
};
|
|
7278
|
+
}
|
|
7279
|
+
function resolveRecordingsCloudClient(env = process.env, options = {}) {
|
|
7280
|
+
if (selectsRecordingsLocalStore(env))
|
|
7281
|
+
return null;
|
|
7282
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
7283
|
+
let resolved;
|
|
7284
|
+
try {
|
|
7285
|
+
resolved = resolveStorageClient("recordings", resolverInputs.env, {
|
|
7286
|
+
fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
|
|
7287
|
+
credentials: resolverInputs.credentials
|
|
7288
|
+
});
|
|
7289
|
+
} catch (error) {
|
|
7290
|
+
throwRecordingsAuthorityFailure(error);
|
|
6165
7291
|
}
|
|
6166
|
-
const
|
|
6167
|
-
|
|
7292
|
+
const client = resolved.client;
|
|
7293
|
+
return client;
|
|
7294
|
+
}
|
|
7295
|
+
|
|
7296
|
+
// src/store.ts
|
|
7297
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
7298
|
+
|
|
7299
|
+
// src/lib/audio-artifact-storage.ts
|
|
7300
|
+
import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
|
|
7301
|
+
import { createHash } from "crypto";
|
|
7302
|
+
import { readFile } from "fs/promises";
|
|
7303
|
+
import { extname } from "path";
|
|
7304
|
+
var SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
7305
|
+
var DEFAULT_S3_PREFIX = "recordings";
|
|
7306
|
+
var AUDIO_CONTENT_TYPES = {
|
|
7307
|
+
wav: "audio/wav",
|
|
7308
|
+
mp3: "audio/mpeg",
|
|
7309
|
+
m4a: "audio/mp4",
|
|
7310
|
+
aac: "audio/aac",
|
|
7311
|
+
webm: "audio/webm",
|
|
7312
|
+
ogg: "audio/ogg",
|
|
7313
|
+
flac: "audio/flac"
|
|
7314
|
+
};
|
|
7315
|
+
function contentTypeForExtension(extension) {
|
|
7316
|
+
return AUDIO_CONTENT_TYPES[extension.toLowerCase()] ?? "application/octet-stream";
|
|
7317
|
+
}
|
|
7318
|
+
|
|
7319
|
+
class AudioArtifactStorage {
|
|
7320
|
+
bucket;
|
|
7321
|
+
prefix;
|
|
7322
|
+
s3;
|
|
7323
|
+
constructor(options = {}) {
|
|
7324
|
+
this.bucket = options.bucket;
|
|
7325
|
+
this.prefix = (options.prefix || DEFAULT_S3_PREFIX).replace(/^\/+|\/+$/g, "");
|
|
7326
|
+
this.s3 = this.bucket ? options.client ?? new AwsS3Client({ region: options.region || process.env.AWS_REGION || "us-east-1" }) : undefined;
|
|
7327
|
+
}
|
|
7328
|
+
get usesS3() {
|
|
7329
|
+
return Boolean(this.bucket);
|
|
7330
|
+
}
|
|
7331
|
+
objectKeyFor(recordingId, sha256, extension) {
|
|
7332
|
+
assertSha256(sha256);
|
|
7333
|
+
const safeExtension = extension.toLowerCase().match(/^[a-z0-9]+/)?.[0] || "bin";
|
|
7334
|
+
return `${this.prefix}/${recordingId}/${sha256}.${safeExtension}`;
|
|
7335
|
+
}
|
|
7336
|
+
async uploadAudio(recordingId, audioPath) {
|
|
7337
|
+
if (!this.bucket || !this.s3)
|
|
7338
|
+
return null;
|
|
7339
|
+
const bytes = await readFileBytes(audioPath);
|
|
7340
|
+
if (!bytes)
|
|
7341
|
+
return null;
|
|
7342
|
+
const sha256 = sha256Hex(bytes);
|
|
7343
|
+
const extension = extensionOf(audioPath);
|
|
7344
|
+
const key = this.objectKeyFor(recordingId, sha256, extension);
|
|
7345
|
+
await this.s3.send(new PutObjectCommand({
|
|
7346
|
+
Bucket: this.bucket,
|
|
7347
|
+
Key: key,
|
|
7348
|
+
Body: bytes,
|
|
7349
|
+
ContentType: contentTypeForExtension(extension)
|
|
7350
|
+
}));
|
|
7351
|
+
return { objectKey: key, sha256, bytes: bytes.byteLength };
|
|
7352
|
+
}
|
|
7353
|
+
async readAudio(objectKey) {
|
|
7354
|
+
if (!this.bucket || !this.s3)
|
|
7355
|
+
return null;
|
|
7356
|
+
try {
|
|
7357
|
+
const response = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
|
7358
|
+
return await bodyToBytes(response.Body);
|
|
7359
|
+
} catch {
|
|
7360
|
+
return null;
|
|
7361
|
+
}
|
|
7362
|
+
}
|
|
7363
|
+
}
|
|
7364
|
+
function resolveAudioArtifactStorage(env = process.env, client) {
|
|
7365
|
+
const bucket = env.HASNA_RECORDINGS_S3_BUCKET || env.RECORDINGS_S3_BUCKET || undefined;
|
|
7366
|
+
const prefix = env.HASNA_RECORDINGS_S3_PREFIX || env.RECORDINGS_S3_PREFIX || undefined;
|
|
7367
|
+
const region = env.RECORDINGS_S3_REGION || env.AWS_REGION || undefined;
|
|
7368
|
+
return new AudioArtifactStorage({ bucket, prefix, region, client });
|
|
7369
|
+
}
|
|
7370
|
+
async function uploadAudioAtCreation(recordingId, audioPath, storage = resolveAudioArtifactStorage()) {
|
|
7371
|
+
if (!audioPath)
|
|
7372
|
+
return null;
|
|
7373
|
+
if (!storage.usesS3)
|
|
7374
|
+
return null;
|
|
6168
7375
|
try {
|
|
6169
|
-
|
|
7376
|
+
const uploaded = await storage.uploadAudio(recordingId, audioPath);
|
|
7377
|
+
if (!uploaded)
|
|
7378
|
+
return null;
|
|
7379
|
+
console.warn(`recordings: uploaded audio for ${recordingId} to ${uploaded.objectKey}`);
|
|
7380
|
+
return uploaded;
|
|
6170
7381
|
} catch (error) {
|
|
6171
|
-
|
|
6172
|
-
return
|
|
7382
|
+
console.warn(`recordings: audio upload failed for ${recordingId} (${String(error instanceof Error ? error.message : error)}); keeping local audio_path only`);
|
|
7383
|
+
return null;
|
|
6173
7384
|
}
|
|
6174
|
-
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
6175
7385
|
}
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
7386
|
+
function assertSha256(value) {
|
|
7387
|
+
if (!SHA256_HEX.test(value)) {
|
|
7388
|
+
throw new Error("audio digest must be a lowercase hex sha-256; refusing to build a storage key from it");
|
|
7389
|
+
}
|
|
7390
|
+
}
|
|
7391
|
+
function sha256Hex(bytes) {
|
|
7392
|
+
const hash = createHash("sha256");
|
|
7393
|
+
hash.update(bytes);
|
|
7394
|
+
return hash.digest("hex");
|
|
7395
|
+
}
|
|
7396
|
+
async function readFileBytes(path) {
|
|
7397
|
+
try {
|
|
7398
|
+
return new Uint8Array(await readFile(path));
|
|
7399
|
+
} catch {
|
|
7400
|
+
return null;
|
|
7401
|
+
}
|
|
7402
|
+
}
|
|
7403
|
+
function extensionOf(path) {
|
|
7404
|
+
const extension = extname(path).replace(/^\./, "").toLowerCase();
|
|
7405
|
+
return extension || "bin";
|
|
7406
|
+
}
|
|
7407
|
+
async function bodyToBytes(body) {
|
|
7408
|
+
if (!body)
|
|
7409
|
+
return new Uint8Array(0);
|
|
7410
|
+
if (body instanceof Uint8Array)
|
|
7411
|
+
return body;
|
|
7412
|
+
if (typeof body.transformToByteArray === "function") {
|
|
7413
|
+
return new Uint8Array(await body.transformToByteArray());
|
|
7414
|
+
}
|
|
7415
|
+
if (typeof body.getReader === "function") {
|
|
7416
|
+
const reader = body.getReader();
|
|
7417
|
+
const chunks2 = [];
|
|
7418
|
+
for (;; ) {
|
|
7419
|
+
const { value, done } = await reader.read();
|
|
7420
|
+
if (done)
|
|
7421
|
+
break;
|
|
7422
|
+
if (value)
|
|
7423
|
+
chunks2.push(value);
|
|
6196
7424
|
}
|
|
6197
|
-
|
|
7425
|
+
return concat(chunks2);
|
|
6198
7426
|
}
|
|
6199
|
-
|
|
6200
|
-
|
|
7427
|
+
const chunks = [];
|
|
7428
|
+
for await (const chunk of body) {
|
|
7429
|
+
chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
6201
7430
|
}
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
7431
|
+
return concat(chunks);
|
|
7432
|
+
}
|
|
7433
|
+
function concat(chunks) {
|
|
7434
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
7435
|
+
const merged = new Uint8Array(new ArrayBuffer(total));
|
|
7436
|
+
let offset = 0;
|
|
7437
|
+
for (const chunk of chunks) {
|
|
7438
|
+
merged.set(chunk, offset);
|
|
7439
|
+
offset += chunk.byteLength;
|
|
6205
7440
|
}
|
|
6206
|
-
return
|
|
7441
|
+
return merged;
|
|
6207
7442
|
}
|
|
6208
7443
|
|
|
6209
7444
|
// src/store.ts
|
|
6210
|
-
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
6211
|
-
var APP = "recordings";
|
|
6212
7445
|
function listQuery(filter) {
|
|
6213
7446
|
if (!filter)
|
|
6214
7447
|
return {};
|
|
@@ -6238,7 +7471,15 @@ var localStore = {
|
|
|
6238
7471
|
mode: "sqlite",
|
|
6239
7472
|
baseUrl: null,
|
|
6240
7473
|
async createRecording(input, idempotencyKey) {
|
|
6241
|
-
return withLocalStoreReaderLease(() =>
|
|
7474
|
+
return withLocalStoreReaderLease(async () => {
|
|
7475
|
+
const recording = createRecording(input, undefined, idempotencyKey);
|
|
7476
|
+
if (!recording.audio_path)
|
|
7477
|
+
return recording;
|
|
7478
|
+
const uploaded = await uploadAudioAtCreation(recording.id, recording.audio_path, localArtifactStorageFor());
|
|
7479
|
+
if (!uploaded)
|
|
7480
|
+
return recording;
|
|
7481
|
+
return updateRecordingAudio(recording.id, uploaded.objectKey, uploaded.sha256, uploaded.bytes) ?? recording;
|
|
7482
|
+
});
|
|
6242
7483
|
},
|
|
6243
7484
|
async getRecording(id) {
|
|
6244
7485
|
return withLocalStoreReaderLease(() => getRecording(id));
|
|
@@ -6432,7 +7673,7 @@ function apiStore(client) {
|
|
|
6432
7673
|
};
|
|
6433
7674
|
}
|
|
6434
7675
|
function recordingPageFingerprint(items) {
|
|
6435
|
-
const hash =
|
|
7676
|
+
const hash = createHash2("sha256");
|
|
6436
7677
|
const ids = items.map((item) => String(item.id ?? "")).sort();
|
|
6437
7678
|
for (const id of ids) {
|
|
6438
7679
|
hash.update(String(id.length));
|
|
@@ -6468,12 +7709,16 @@ async function countStoreRecordings(store, filter) {
|
|
|
6468
7709
|
throw new Error(`Legacy Store exceeded ${maxPageRequests} pages while counting recordings`);
|
|
6469
7710
|
}
|
|
6470
7711
|
var cached = null;
|
|
6471
|
-
|
|
6472
|
-
|
|
7712
|
+
var localArtifactStorageOverride;
|
|
7713
|
+
function localArtifactStorageFor() {
|
|
7714
|
+
return localArtifactStorageOverride ?? resolveAudioArtifactStorage();
|
|
7715
|
+
}
|
|
7716
|
+
function getStore(env = process.env, options = {}) {
|
|
7717
|
+
if (env === process.env && cached && options.credentials === undefined)
|
|
6473
7718
|
return cached;
|
|
6474
|
-
const
|
|
6475
|
-
const store =
|
|
6476
|
-
if (env === process.env)
|
|
7719
|
+
const client = resolveRecordingsCloudClient(env, options);
|
|
7720
|
+
const store = client ? apiStore(client) : localStore;
|
|
7721
|
+
if (env === process.env && options.credentials === undefined)
|
|
6477
7722
|
cached = store;
|
|
6478
7723
|
return store;
|
|
6479
7724
|
}
|
|
@@ -6502,7 +7747,7 @@ var _client = null;
|
|
|
6502
7747
|
var _clientApiKey = null;
|
|
6503
7748
|
function getClient(config) {
|
|
6504
7749
|
if (!config.openai_api_key) {
|
|
6505
|
-
throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var
|
|
7750
|
+
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
7751
|
}
|
|
6507
7752
|
if (_client && _clientApiKey === config.openai_api_key)
|
|
6508
7753
|
return _client;
|
|
@@ -6731,7 +7976,6 @@ function currentMachineId(env = process.env, hostName = hostname()) {
|
|
|
6731
7976
|
|
|
6732
7977
|
// src/mcp/index.ts
|
|
6733
7978
|
var config = loadConfig();
|
|
6734
|
-
ensureDataDir(config);
|
|
6735
7979
|
function runtimeConfig() {
|
|
6736
7980
|
return {
|
|
6737
7981
|
...loadConfig(),
|
|
@@ -7419,6 +8663,16 @@ async function main() {
|
|
|
7419
8663
|
printHelp();
|
|
7420
8664
|
return;
|
|
7421
8665
|
}
|
|
8666
|
+
const transportStatus = getRecordingsTransportStatus();
|
|
8667
|
+
if (!transportStatus.ok) {
|
|
8668
|
+
const issue = transportStatus.issues[0] ?? "REMOTE_API_CONFIG_MISSING";
|
|
8669
|
+
console.error(`ERROR: ${issue}`);
|
|
8670
|
+
process.exit(1);
|
|
8671
|
+
}
|
|
8672
|
+
if (selectsRecordingsLocalStore(process.env)) {
|
|
8673
|
+
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.");
|
|
8674
|
+
ensureDataDir(config);
|
|
8675
|
+
}
|
|
7422
8676
|
if (isStdioMode(args)) {
|
|
7423
8677
|
const transport = new StdioServerTransport;
|
|
7424
8678
|
await buildServer().connect(transport);
|