@hasna/recordings 0.3.13 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -37
- package/bun.lock +37 -38
- package/dist/cli/index.js +1884 -615
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2858 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1689 -456
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +566 -284
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1334 -871
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +9 -8
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +20 -16
- package/scripts/migrate.ts +4 -20
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +6 -6
- package/src/native/Recordings/App/SidebarView.swift +0 -212
package/dist/storage.js
CHANGED
|
@@ -244,6 +244,9 @@ var DEFAULT_CONFIG = {
|
|
|
244
244
|
db_path: "",
|
|
245
245
|
audio_dir: "",
|
|
246
246
|
max_recording_seconds: 1800,
|
|
247
|
+
s3_bucket: "",
|
|
248
|
+
s3_prefix: "",
|
|
249
|
+
s3_region: "",
|
|
247
250
|
config_warnings: []
|
|
248
251
|
};
|
|
249
252
|
function loadConfig(configPath) {
|
|
@@ -270,6 +273,9 @@ function loadConfig(configPath) {
|
|
|
270
273
|
if (process.env.RECORDINGS_API_KEY) {
|
|
271
274
|
config.openai_api_key = process.env.RECORDINGS_API_KEY;
|
|
272
275
|
}
|
|
276
|
+
if (process.env.RECORDINGS_OPENAI_API_KEY) {
|
|
277
|
+
config.openai_api_key = process.env.RECORDINGS_OPENAI_API_KEY;
|
|
278
|
+
}
|
|
273
279
|
if (process.env.RECORDINGS_ENHANCEMENT_KEY) {
|
|
274
280
|
config.enhancement_api_key = process.env.RECORDINGS_ENHANCEMENT_KEY;
|
|
275
281
|
}
|
|
@@ -313,14 +319,26 @@ function loadConfig(configPath) {
|
|
|
313
319
|
if (process.env.RECORDINGS_AUDIO_DIR) {
|
|
314
320
|
config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
|
|
315
321
|
}
|
|
322
|
+
if (process.env.HASNA_RECORDINGS_S3_BUCKET) {
|
|
323
|
+
config.s3_bucket = process.env.HASNA_RECORDINGS_S3_BUCKET;
|
|
324
|
+
} else if (process.env.RECORDINGS_S3_BUCKET) {
|
|
325
|
+
config.s3_bucket = process.env.RECORDINGS_S3_BUCKET;
|
|
326
|
+
}
|
|
327
|
+
if (process.env.HASNA_RECORDINGS_S3_PREFIX) {
|
|
328
|
+
config.s3_prefix = process.env.HASNA_RECORDINGS_S3_PREFIX;
|
|
329
|
+
} else if (process.env.RECORDINGS_S3_PREFIX) {
|
|
330
|
+
config.s3_prefix = process.env.RECORDINGS_S3_PREFIX;
|
|
331
|
+
}
|
|
332
|
+
if (process.env.RECORDINGS_S3_REGION) {
|
|
333
|
+
config.s3_region = process.env.RECORDINGS_S3_REGION;
|
|
334
|
+
} else if (process.env.AWS_REGION) {
|
|
335
|
+
config.s3_region = process.env.AWS_REGION;
|
|
336
|
+
}
|
|
316
337
|
if (process.env.RECORDINGS_MAX_SECONDS) {
|
|
317
338
|
config.max_recording_seconds = parseInt(process.env.RECORDINGS_MAX_SECONDS, 10);
|
|
318
339
|
}
|
|
319
|
-
if (!config.openai_api_key) {
|
|
320
|
-
config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
|
|
321
|
-
}
|
|
322
340
|
if (!config.enhancement_api_key) {
|
|
323
|
-
config.enhancement_api_key = config.openai_api_key
|
|
341
|
+
config.enhancement_api_key = config.openai_api_key;
|
|
324
342
|
}
|
|
325
343
|
if (!explicitTranscriberModel) {
|
|
326
344
|
config.transcriber_model = config.enhancement_model;
|
|
@@ -500,51 +518,6 @@ function mergeDirectoryContents(sourceDir, targetDir) {
|
|
|
500
518
|
}
|
|
501
519
|
}
|
|
502
520
|
}
|
|
503
|
-
function loadSecretKey(keyName) {
|
|
504
|
-
const secretsPath = join2(getHomeDir(), ".secrets");
|
|
505
|
-
if (!existsSync(secretsPath))
|
|
506
|
-
return "";
|
|
507
|
-
for (const candidate of listSecretFiles(secretsPath)) {
|
|
508
|
-
try {
|
|
509
|
-
const content = readFileSync(candidate, "utf-8");
|
|
510
|
-
const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
|
|
511
|
-
const doubleQuotedValue = match?.[1];
|
|
512
|
-
if (doubleQuotedValue !== undefined)
|
|
513
|
-
return doubleQuotedValue;
|
|
514
|
-
const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
|
|
515
|
-
const singleQuotedValue = match2?.[1];
|
|
516
|
-
if (singleQuotedValue !== undefined)
|
|
517
|
-
return singleQuotedValue;
|
|
518
|
-
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
519
|
-
const unquotedValue = match3?.[1];
|
|
520
|
-
if (unquotedValue !== undefined)
|
|
521
|
-
return unquotedValue.trim().replace(/^["']|["']$/g, "");
|
|
522
|
-
} catch {}
|
|
523
|
-
}
|
|
524
|
-
return "";
|
|
525
|
-
}
|
|
526
|
-
function listSecretFiles(path) {
|
|
527
|
-
try {
|
|
528
|
-
const stats = statSync(path);
|
|
529
|
-
if (stats.isFile())
|
|
530
|
-
return [path];
|
|
531
|
-
if (!stats.isDirectory())
|
|
532
|
-
return [];
|
|
533
|
-
return readdirSync(path).sort().flatMap((entry) => {
|
|
534
|
-
const child = join2(path, entry);
|
|
535
|
-
try {
|
|
536
|
-
const childStats = statSync(child);
|
|
537
|
-
if (childStats.isDirectory())
|
|
538
|
-
return listSecretFiles(child);
|
|
539
|
-
if (childStats.isFile() && child.endsWith(".env"))
|
|
540
|
-
return [child];
|
|
541
|
-
} catch {}
|
|
542
|
-
return [];
|
|
543
|
-
});
|
|
544
|
-
} catch {
|
|
545
|
-
return [];
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
521
|
function getHomeDir() {
|
|
549
522
|
return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
|
|
550
523
|
}
|
|
@@ -641,6 +614,11 @@ var MIGRATIONS = [
|
|
|
641
614
|
`,
|
|
642
615
|
`
|
|
643
616
|
ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
|
617
|
+
`,
|
|
618
|
+
`
|
|
619
|
+
ALTER TABLE recordings ADD COLUMN audio_object_key TEXT;
|
|
620
|
+
ALTER TABLE recordings ADD COLUMN audio_sha256 TEXT;
|
|
621
|
+
ALTER TABLE recordings ADD COLUMN audio_bytes INTEGER;
|
|
644
622
|
`
|
|
645
623
|
];
|
|
646
624
|
var CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
|
|
@@ -696,6 +674,9 @@ function repairSchemaDrift(db) {
|
|
|
696
674
|
ensureColumn(db, "recordings", "task_list_id", "TEXT");
|
|
697
675
|
ensureColumn(db, "recordings", "machine_id", "TEXT");
|
|
698
676
|
ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
|
|
677
|
+
ensureColumn(db, "recordings", "audio_object_key", "TEXT");
|
|
678
|
+
ensureColumn(db, "recordings", "audio_sha256", "TEXT");
|
|
679
|
+
ensureColumn(db, "recordings", "audio_bytes", "INTEGER");
|
|
699
680
|
ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
|
|
700
681
|
}
|
|
701
682
|
function ensureColumn(db, table, column, definition) {
|
|
@@ -771,6 +752,9 @@ function parseRow(row) {
|
|
|
771
752
|
return {
|
|
772
753
|
id: row["id"],
|
|
773
754
|
audio_path: row["audio_path"] || null,
|
|
755
|
+
audio_object_key: row["audio_object_key"] || null,
|
|
756
|
+
audio_sha256: row["audio_sha256"] || null,
|
|
757
|
+
audio_bytes: Number(row["audio_bytes"] ?? 0) || null,
|
|
774
758
|
raw_text: row["raw_text"],
|
|
775
759
|
processed_text: row["processed_text"] || null,
|
|
776
760
|
processing_mode: row["processing_mode"] || "raw",
|
|
@@ -802,9 +786,9 @@ function createRecording(input, db, idempotencyKey) {
|
|
|
802
786
|
}
|
|
803
787
|
const tagsJson = JSON.stringify(input.tags || []);
|
|
804
788
|
const metadataJson = JSON.stringify(input.metadata || {});
|
|
805
|
-
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)
|
|
806
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
807
|
-
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);
|
|
789
|
+
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)
|
|
790
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
791
|
+
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);
|
|
808
792
|
if (insertResult.changes === 0) {
|
|
809
793
|
const existing = getRecording(id, d);
|
|
810
794
|
if (existing?.id === id)
|
|
@@ -821,6 +805,13 @@ function createRecording(input, db, idempotencyKey) {
|
|
|
821
805
|
});
|
|
822
806
|
return create();
|
|
823
807
|
}
|
|
808
|
+
function updateRecordingAudio(id, audioObjectKey, audioSha256, audioBytes, db) {
|
|
809
|
+
const d = db || getDatabase();
|
|
810
|
+
const result = d.query(`UPDATE recordings SET audio_object_key = ?, audio_sha256 = ?, audio_bytes = ? WHERE id = ?`).run(audioObjectKey, audioSha256, audioBytes, id);
|
|
811
|
+
if (result.changes === 0)
|
|
812
|
+
return null;
|
|
813
|
+
return getRecording(id, d);
|
|
814
|
+
}
|
|
824
815
|
function getRecording(id, db) {
|
|
825
816
|
const d = db || getDatabase();
|
|
826
817
|
let row = d.query("SELECT * FROM recordings WHERE id = ?").get(id);
|
|
@@ -1022,7 +1013,7 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1022
1013
|
// package.json
|
|
1023
1014
|
var package_default = {
|
|
1024
1015
|
name: "@hasna/recordings",
|
|
1025
|
-
version: "0.
|
|
1016
|
+
version: "0.4.0",
|
|
1026
1017
|
type: "module",
|
|
1027
1018
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
1028
1019
|
repository: {
|
|
@@ -1056,10 +1047,10 @@ var package_default = {
|
|
|
1056
1047
|
scripts: {
|
|
1057
1048
|
clean: "rm -rf dist",
|
|
1058
1049
|
build: "bun run clean && bun run build:cli && bun run build:mcp && bun run build:serve && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
1059
|
-
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
1060
|
-
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
1061
|
-
"build:serve": "bun build src/server/index.ts --target=bun --outfile=dist/server/index.js --external=@modelcontextprotocol/sdk --external=@hasna/contracts --external=openai --external=pg",
|
|
1062
|
-
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai",
|
|
1050
|
+
"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",
|
|
1051
|
+
"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",
|
|
1052
|
+
"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",
|
|
1053
|
+
"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",
|
|
1063
1054
|
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
1064
1055
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
1065
1056
|
migrate: "bun run scripts/migrate.ts",
|
|
@@ -1069,7 +1060,7 @@ var package_default = {
|
|
|
1069
1060
|
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
1070
1061
|
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
1071
1062
|
"verify:ci-native": "bun scripts/ci-native-build.ts",
|
|
1072
|
-
"test:gated": 'bun scripts/ci-linux-suite.ts --check && RECORDINGS_TEST_TIMEOUT_MS="${RECORDINGS_TEST_TIMEOUT_MS:-120000}" bun test --timeout "$RECORDINGS_TEST_TIMEOUT_MS" $(bun scripts/ci-linux-suite.ts --gated)',
|
|
1063
|
+
"test:gated": 'bun scripts/ci-linux-suite.ts --check && RECORDINGS_TEST_TIMEOUT_MS="${RECORDINGS_TEST_TIMEOUT_MS:-120000}" bun test --timeout "${RECORDINGS_TEST_TIMEOUT_MS:-120000}" $(bun scripts/ci-linux-suite.ts --gated)',
|
|
1073
1064
|
"test:vacuity-battery": 'bun scripts/vacuity-manifest-gen.ts > "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv" && bun scripts/vacuity-mutation-battery.ts "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv"',
|
|
1074
1065
|
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
1075
1066
|
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
@@ -1106,7 +1097,8 @@ var package_default = {
|
|
|
1106
1097
|
"LICENSE"
|
|
1107
1098
|
],
|
|
1108
1099
|
dependencies: {
|
|
1109
|
-
"@
|
|
1100
|
+
"@aws-sdk/client-s3": "^3.1007.0",
|
|
1101
|
+
"@hasna/contracts": "1.0.2",
|
|
1110
1102
|
"@hasna/events": "0.1.11",
|
|
1111
1103
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
1112
1104
|
chalk: "^5.4.1",
|
|
@@ -1116,7 +1108,7 @@ var package_default = {
|
|
|
1116
1108
|
zod: "^3.24.2"
|
|
1117
1109
|
},
|
|
1118
1110
|
devDependencies: {
|
|
1119
|
-
"@types/bun": "
|
|
1111
|
+
"@types/bun": "1.3.14",
|
|
1120
1112
|
"@types/pg": "^8.11.11",
|
|
1121
1113
|
"node-api-headers": "1.9.0",
|
|
1122
1114
|
typescript: "^5.8.2"
|
|
@@ -1140,8 +1132,12 @@ function saveFeedback(input) {
|
|
|
1140
1132
|
|
|
1141
1133
|
// ../contracts/dist/client/transport.js
|
|
1142
1134
|
import { isIP } from "net";
|
|
1143
|
-
import {
|
|
1144
|
-
import {
|
|
1135
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1136
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync2 } from "fs";
|
|
1137
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
1138
|
+
import { createRequire } from "module";
|
|
1139
|
+
import { hostname as osHostname } from "os";
|
|
1140
|
+
import { isAbsolute as isAbsolute2, join as join3 } from "path";
|
|
1145
1141
|
function envToken(name) {
|
|
1146
1142
|
return name.toUpperCase().replace(/-/g, "_");
|
|
1147
1143
|
}
|
|
@@ -1156,6 +1152,9 @@ function credentialOverrideEnvKey(name) {
|
|
|
1156
1152
|
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
1157
1153
|
}
|
|
1158
1154
|
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
1155
|
+
function credentialPointerEnvKey(name) {
|
|
1156
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
1157
|
+
}
|
|
1159
1158
|
|
|
1160
1159
|
class CredentialResolutionError extends Error {
|
|
1161
1160
|
appName;
|
|
@@ -1167,34 +1166,70 @@ class CredentialResolutionError extends Error {
|
|
|
1167
1166
|
this.attempted = attempted;
|
|
1168
1167
|
}
|
|
1169
1168
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1169
|
+
|
|
1170
|
+
class CredentialFileUnsafeError extends Error {
|
|
1171
|
+
path;
|
|
1172
|
+
constructor(path, reason) {
|
|
1173
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
1174
|
+
this.name = "CredentialFileUnsafeError";
|
|
1175
|
+
this.path = path;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
1179
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
1180
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
1181
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
1182
|
+
var CONFIG_SUBDIR = "config";
|
|
1183
|
+
var CREDENTIALS_FILE = "credentials";
|
|
1184
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
1185
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
1186
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
1187
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
1174
1188
|
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
1175
1189
|
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1176
1190
|
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
1177
1191
|
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
1192
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
1178
1193
|
function homeDir(env) {
|
|
1179
1194
|
const home = env.HOME?.trim();
|
|
1180
1195
|
return home ? home : null;
|
|
1181
1196
|
}
|
|
1182
|
-
function
|
|
1183
|
-
|
|
1197
|
+
function absoluteOverride(env, key) {
|
|
1198
|
+
const value = env[key]?.trim();
|
|
1199
|
+
return value && isAbsolute2(value) ? value : null;
|
|
1184
1200
|
}
|
|
1185
|
-
function
|
|
1201
|
+
function hasnaHomeDir(env) {
|
|
1202
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
1203
|
+
if (override)
|
|
1204
|
+
return override;
|
|
1186
1205
|
const home = homeDir(env);
|
|
1187
|
-
|
|
1206
|
+
return home ? join3(home, HASNA_HOME_DIR) : null;
|
|
1207
|
+
}
|
|
1208
|
+
function appConfigDir(name, env) {
|
|
1209
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
1210
|
+
if (configRoot)
|
|
1211
|
+
return join3(configRoot, name);
|
|
1212
|
+
const root = hasnaHomeDir(env);
|
|
1213
|
+
return root ? join3(root, name, CONFIG_SUBDIR) : null;
|
|
1214
|
+
}
|
|
1215
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
1216
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
1188
1217
|
return [];
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1218
|
+
const directory = appConfigDir(name, env);
|
|
1219
|
+
if (!directory)
|
|
1220
|
+
return [];
|
|
1221
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
1222
|
+
return [{ path: join3(directory, file), tier: "disk" }];
|
|
1223
|
+
}
|
|
1224
|
+
function credentialDiskSources(name, env) {
|
|
1225
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
1226
|
+
}
|
|
1227
|
+
function profileDiskSources(name, env, profile) {
|
|
1228
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
1195
1229
|
}
|
|
1196
1230
|
function parseEnvFile(text) {
|
|
1197
1231
|
const values = new Map;
|
|
1232
|
+
const unusable = new Set;
|
|
1198
1233
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
1199
1234
|
const line = rawLine.trim();
|
|
1200
1235
|
if (line.length === 0 || line.startsWith("#"))
|
|
@@ -1209,38 +1244,81 @@ function parseEnvFile(text) {
|
|
|
1209
1244
|
let value = withoutExport.slice(equals + 1).trim();
|
|
1210
1245
|
const quote = value[0];
|
|
1211
1246
|
if (quote === '"' || quote === "'") {
|
|
1212
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
1247
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
1248
|
+
unusable.add(key);
|
|
1213
1249
|
continue;
|
|
1250
|
+
}
|
|
1214
1251
|
value = value.slice(1, -1);
|
|
1215
1252
|
}
|
|
1216
|
-
if (value.length === 0)
|
|
1253
|
+
if (value.trim().length === 0) {
|
|
1254
|
+
unusable.add(key);
|
|
1217
1255
|
continue;
|
|
1256
|
+
}
|
|
1257
|
+
if (values.has(key) && values.get(key) !== value)
|
|
1258
|
+
unusable.add(key);
|
|
1218
1259
|
values.set(key, value);
|
|
1219
1260
|
}
|
|
1220
|
-
return values;
|
|
1261
|
+
return { values, unusable };
|
|
1262
|
+
}
|
|
1263
|
+
function configFileModeAllowed(mode) {
|
|
1264
|
+
const permissions = mode & 4095;
|
|
1265
|
+
return permissions === 256 || permissions === 384;
|
|
1266
|
+
}
|
|
1267
|
+
function configFileReadsCoherent(before, after) {
|
|
1268
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
1221
1269
|
}
|
|
1222
1270
|
function readAppConfigFile(path) {
|
|
1223
|
-
|
|
1271
|
+
const unsafe = (reason) => {
|
|
1272
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
1273
|
+
};
|
|
1274
|
+
let fd = -1;
|
|
1224
1275
|
try {
|
|
1225
|
-
|
|
1226
|
-
|
|
1276
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
const code = error.code;
|
|
1279
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
1227
1280
|
return null;
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1281
|
+
if (code === "ELOOP")
|
|
1282
|
+
unsafe("the path is a symlink");
|
|
1283
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
1284
|
+
}
|
|
1285
|
+
try {
|
|
1286
|
+
const before = fstatSync(fd);
|
|
1287
|
+
if (!before.isFile())
|
|
1288
|
+
unsafe("the path is not a regular file");
|
|
1289
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
1290
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
1291
|
+
}
|
|
1292
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
1293
|
+
if (uid !== undefined && before.uid !== uid)
|
|
1294
|
+
unsafe("the file is not owned by the current user");
|
|
1295
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
1296
|
+
unsafe("the file exceeds the size limit");
|
|
1297
|
+
const bytes = readFileSync2(fd);
|
|
1298
|
+
const after = fstatSync(fd);
|
|
1299
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
1300
|
+
unsafe("the file changed while being read");
|
|
1301
|
+
}
|
|
1302
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
1303
|
+
} finally {
|
|
1304
|
+
if (fd !== -1)
|
|
1305
|
+
closeSync(fd);
|
|
1231
1306
|
}
|
|
1232
|
-
return parseEnvFile(text);
|
|
1233
1307
|
}
|
|
1234
1308
|
function readCredentialFile(path, apiKeyKeys) {
|
|
1235
|
-
const
|
|
1236
|
-
if (!
|
|
1309
|
+
const parsed = readAppConfigFile(path);
|
|
1310
|
+
if (!parsed)
|
|
1237
1311
|
return null;
|
|
1238
1312
|
for (const key of apiKeyKeys) {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1313
|
+
if (parsed.unusable.has(key)) {
|
|
1314
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
1315
|
+
}
|
|
1242
1316
|
}
|
|
1243
|
-
|
|
1317
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
1318
|
+
if (new Set(values).size > 1) {
|
|
1319
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
1320
|
+
}
|
|
1321
|
+
return values[0] ?? null;
|
|
1244
1322
|
}
|
|
1245
1323
|
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
1246
1324
|
function appConfigDiskValue(name, env, keys) {
|
|
@@ -1248,11 +1326,19 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
1248
1326
|
if (wanted.length === 0)
|
|
1249
1327
|
return null;
|
|
1250
1328
|
for (const path of credentialDiskSources(name, env)) {
|
|
1251
|
-
const
|
|
1252
|
-
if (!
|
|
1329
|
+
const parsed = readAppConfigFile(path);
|
|
1330
|
+
if (!parsed)
|
|
1253
1331
|
continue;
|
|
1332
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
1333
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
1334
|
+
}
|
|
1335
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
1336
|
+
if (new Set(values).size > 1)
|
|
1337
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
1254
1338
|
for (const key of wanted) {
|
|
1255
|
-
|
|
1339
|
+
if (parsed.unusable.has(key))
|
|
1340
|
+
return { key, value: "", path, unusable: true };
|
|
1341
|
+
const value = parsed.values.get(key)?.trim();
|
|
1256
1342
|
if (value)
|
|
1257
1343
|
return { key, value, path };
|
|
1258
1344
|
}
|
|
@@ -1260,20 +1346,21 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
1260
1346
|
return null;
|
|
1261
1347
|
}
|
|
1262
1348
|
function assertUsableCredential(appName, source, value) {
|
|
1349
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
1350
|
+
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]);
|
|
1351
|
+
}
|
|
1263
1352
|
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
1264
1353
|
return;
|
|
1265
1354
|
throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
1266
1355
|
}
|
|
1267
1356
|
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
1268
1357
|
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
1269
|
-
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
1270
1358
|
function sealCredential(fields) {
|
|
1271
1359
|
const { apiKey } = fields;
|
|
1272
1360
|
const visible = {
|
|
1273
1361
|
tier: fields.tier,
|
|
1274
1362
|
source: fields.source,
|
|
1275
1363
|
deliberate: fields.deliberate,
|
|
1276
|
-
deprecated: fields.deprecated,
|
|
1277
1364
|
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
1278
1365
|
warning: fields.warning
|
|
1279
1366
|
};
|
|
@@ -1284,6 +1371,14 @@ function sealCredential(fields) {
|
|
|
1284
1371
|
writable: false,
|
|
1285
1372
|
configurable: false
|
|
1286
1373
|
});
|
|
1374
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
1375
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
1376
|
+
value: fields.pointerVaultKey,
|
|
1377
|
+
enumerable: false,
|
|
1378
|
+
writable: false,
|
|
1379
|
+
configurable: false
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1287
1382
|
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
1288
1383
|
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
1289
1384
|
enumerable: false,
|
|
@@ -1298,88 +1393,142 @@ function sealCredential(fields) {
|
|
|
1298
1393
|
});
|
|
1299
1394
|
return Object.freeze(sealed);
|
|
1300
1395
|
}
|
|
1301
|
-
function isSealedCredential(credential) {
|
|
1302
|
-
return credential[CREDENTIAL_SEAL] === true;
|
|
1303
|
-
}
|
|
1304
|
-
function explicitCredential(appName, apiKey) {
|
|
1305
|
-
const source = "explicit apiKey option";
|
|
1306
|
-
assertUsableCredential(appName, source, apiKey);
|
|
1307
|
-
return sealCredential({
|
|
1308
|
-
apiKey,
|
|
1309
|
-
tier: "argument",
|
|
1310
|
-
source,
|
|
1311
|
-
deliberate: true,
|
|
1312
|
-
deprecated: false,
|
|
1313
|
-
diskCandidates: [],
|
|
1314
|
-
warning: null
|
|
1315
|
-
});
|
|
1316
|
-
}
|
|
1317
|
-
function validateAndSealResolvedCredential(appName, credential) {
|
|
1318
|
-
const apiKey = credential.apiKey;
|
|
1319
|
-
assertUsableCredential(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
1320
|
-
if (!isSealedCredential(credential)) {
|
|
1321
|
-
return sealCredential({
|
|
1322
|
-
apiKey,
|
|
1323
|
-
tier: "argument",
|
|
1324
|
-
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
1325
|
-
deliberate: true,
|
|
1326
|
-
deprecated: false,
|
|
1327
|
-
diskCandidates: [],
|
|
1328
|
-
warning: null
|
|
1329
|
-
});
|
|
1330
|
-
}
|
|
1331
|
-
return sealCredential({
|
|
1332
|
-
apiKey,
|
|
1333
|
-
tier: credential.tier,
|
|
1334
|
-
source: credential.source,
|
|
1335
|
-
deliberate: credential.deliberate,
|
|
1336
|
-
deprecated: credential.deprecated,
|
|
1337
|
-
diskCandidates: credential.diskCandidates,
|
|
1338
|
-
warning: credential.warning
|
|
1339
|
-
});
|
|
1340
|
-
}
|
|
1341
1396
|
function firstEnvValue(env, keys) {
|
|
1342
1397
|
for (const key of keys) {
|
|
1398
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
1399
|
+
continue;
|
|
1343
1400
|
const value = env[key]?.trim();
|
|
1344
1401
|
if (value)
|
|
1345
1402
|
return { key, value };
|
|
1346
1403
|
}
|
|
1347
1404
|
return null;
|
|
1348
1405
|
}
|
|
1349
|
-
var
|
|
1350
|
-
function
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1406
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
1407
|
+
function isAmbientEnvironment(env) {
|
|
1408
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
1409
|
+
}
|
|
1410
|
+
function defaultKeychainRunner(argv) {
|
|
1411
|
+
const result = spawnSync2(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
1412
|
+
encoding: "utf8",
|
|
1413
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1414
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
1415
|
+
});
|
|
1416
|
+
return {
|
|
1417
|
+
status: result.status,
|
|
1418
|
+
stdout: result.stdout ?? "",
|
|
1419
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
function keychainTierEnabled(env, options) {
|
|
1423
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
1424
|
+
return false;
|
|
1425
|
+
if (options.enabled !== undefined)
|
|
1426
|
+
return options.enabled;
|
|
1427
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
1428
|
+
}
|
|
1429
|
+
function keychainAccount(env, options) {
|
|
1430
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
1431
|
+
if (station)
|
|
1432
|
+
return station;
|
|
1433
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
1434
|
+
if (host)
|
|
1435
|
+
return host;
|
|
1436
|
+
const user = env.USER?.trim();
|
|
1437
|
+
return user || null;
|
|
1438
|
+
}
|
|
1439
|
+
function keychainFailureHint(text) {
|
|
1440
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
1441
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
1442
|
+
return clean ? `: ${clean}` : "";
|
|
1443
|
+
}
|
|
1444
|
+
function readKeychainItem(name, env, kind, options) {
|
|
1445
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
1446
|
+
return null;
|
|
1447
|
+
const account = keychainAccount(env, options);
|
|
1448
|
+
if (!account)
|
|
1449
|
+
return null;
|
|
1450
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
1451
|
+
const source = `keychain:${service}@${account}`;
|
|
1452
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
1453
|
+
let result;
|
|
1454
|
+
try {
|
|
1455
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
1458
|
+
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]);
|
|
1459
|
+
}
|
|
1460
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
1461
|
+
return null;
|
|
1462
|
+
if (result.status !== 0) {
|
|
1463
|
+
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]);
|
|
1464
|
+
}
|
|
1465
|
+
const value = result.stdout.trim();
|
|
1466
|
+
if (!value) {
|
|
1467
|
+
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]);
|
|
1468
|
+
}
|
|
1469
|
+
return { value, source };
|
|
1470
|
+
}
|
|
1471
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
1472
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
1358
1473
|
}
|
|
1359
|
-
function
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1474
|
+
function snapshotClientEnvironment(name, env) {
|
|
1475
|
+
const keys = clientTransportEnvKeys(name);
|
|
1476
|
+
const ambient = isAmbientEnvironment(env);
|
|
1477
|
+
const snapshot = Object.create(null);
|
|
1478
|
+
for (const key of [
|
|
1479
|
+
...keys.apiUrlKeys,
|
|
1480
|
+
...keys.apiKeyKeys,
|
|
1481
|
+
credentialOverrideEnvKey(name),
|
|
1482
|
+
credentialPointerEnvKey(name),
|
|
1483
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
1484
|
+
"HOME",
|
|
1485
|
+
HASNA_HOME_ENV_KEY,
|
|
1486
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
1487
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
1488
|
+
"USER"
|
|
1489
|
+
]) {
|
|
1490
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
1491
|
+
if (!descriptor)
|
|
1492
|
+
continue;
|
|
1493
|
+
if (!("value" in descriptor)) {
|
|
1494
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
1495
|
+
}
|
|
1496
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
1497
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
1498
|
+
}
|
|
1499
|
+
snapshot[key] = descriptor.value;
|
|
1500
|
+
}
|
|
1501
|
+
if (ambient) {
|
|
1502
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
1503
|
+
value: true,
|
|
1504
|
+
enumerable: false,
|
|
1505
|
+
writable: false,
|
|
1506
|
+
configurable: false
|
|
1507
|
+
});
|
|
1363
1508
|
}
|
|
1509
|
+
return Object.freeze(snapshot);
|
|
1364
1510
|
}
|
|
1365
1511
|
function resolveCredential(name, env, options = {}) {
|
|
1512
|
+
env = snapshotClientEnvironment(name, env);
|
|
1366
1513
|
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
1367
1514
|
const diskPaths = credentialDiskSources(name, env);
|
|
1368
|
-
|
|
1369
|
-
|
|
1515
|
+
if (options.apiKey !== undefined) {
|
|
1516
|
+
const explicitKey = options.apiKey.trim();
|
|
1517
|
+
if (!explicitKey) {
|
|
1518
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
1519
|
+
}
|
|
1370
1520
|
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
1371
1521
|
return sealCredential({
|
|
1372
1522
|
apiKey: explicitKey,
|
|
1373
1523
|
tier: "argument",
|
|
1374
1524
|
source: "explicit apiKey argument",
|
|
1375
1525
|
deliberate: true,
|
|
1376
|
-
deprecated: false,
|
|
1377
1526
|
diskCandidates: diskPaths,
|
|
1378
1527
|
warning: null
|
|
1379
1528
|
});
|
|
1380
1529
|
}
|
|
1381
1530
|
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
1382
|
-
const overrideRaw = env[overrideKeyName];
|
|
1531
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
1383
1532
|
if (overrideRaw !== undefined) {
|
|
1384
1533
|
const override = overrideRaw.trim();
|
|
1385
1534
|
if (!override) {
|
|
@@ -1391,12 +1540,38 @@ function resolveCredential(name, env, options = {}) {
|
|
|
1391
1540
|
tier: "override",
|
|
1392
1541
|
source: overrideKeyName,
|
|
1393
1542
|
deliberate: true,
|
|
1394
|
-
deprecated: false,
|
|
1395
1543
|
diskCandidates: diskPaths,
|
|
1396
1544
|
warning: null
|
|
1397
1545
|
});
|
|
1398
1546
|
}
|
|
1399
|
-
const
|
|
1547
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
1548
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
1549
|
+
if (pointerRaw !== undefined) {
|
|
1550
|
+
const pointer = pointerRaw.trim();
|
|
1551
|
+
if (!pointer) {
|
|
1552
|
+
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]);
|
|
1553
|
+
}
|
|
1554
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
1555
|
+
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]);
|
|
1556
|
+
}
|
|
1557
|
+
return sealCredential({
|
|
1558
|
+
apiKey: "",
|
|
1559
|
+
pointerVaultKey: pointer,
|
|
1560
|
+
tier: "pointer",
|
|
1561
|
+
source: pointerKeyName,
|
|
1562
|
+
deliberate: true,
|
|
1563
|
+
diskCandidates: diskPaths,
|
|
1564
|
+
warning: null
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
1568
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
1569
|
+
}
|
|
1570
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
1571
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
1572
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
1573
|
+
}
|
|
1574
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
1400
1575
|
if (profile) {
|
|
1401
1576
|
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
1402
1577
|
if (!SAFE_PROFILE.test(profile)) {
|
|
@@ -1412,7 +1587,6 @@ function resolveCredential(name, env, options = {}) {
|
|
|
1412
1587
|
tier: "profile",
|
|
1413
1588
|
source: path,
|
|
1414
1589
|
deliberate: true,
|
|
1415
|
-
deprecated: false,
|
|
1416
1590
|
diskCandidates: paths,
|
|
1417
1591
|
warning: null
|
|
1418
1592
|
});
|
|
@@ -1420,51 +1594,67 @@ function resolveCredential(name, env, options = {}) {
|
|
|
1420
1594
|
}
|
|
1421
1595
|
throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
|
|
1422
1596
|
}
|
|
1423
|
-
const
|
|
1597
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
1598
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
1599
|
+
if (blankEnv) {
|
|
1600
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
1601
|
+
}
|
|
1602
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
1603
|
+
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));
|
|
1604
|
+
}
|
|
1605
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
1606
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
1607
|
+
if (keychainHit) {
|
|
1608
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
1609
|
+
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;
|
|
1610
|
+
return sealCredential({
|
|
1611
|
+
apiKey: keychainHit.value,
|
|
1612
|
+
tier: "keychain",
|
|
1613
|
+
source: keychainHit.source,
|
|
1614
|
+
deliberate: false,
|
|
1615
|
+
diskCandidates: diskPaths,
|
|
1616
|
+
warning
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
1620
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
1424
1621
|
if (diskHits.length > 0) {
|
|
1425
1622
|
const winner = diskHits[0];
|
|
1426
|
-
assertUsableCredential(name, winner.path, winner.value);
|
|
1623
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
1427
1624
|
const divergentSources = [
|
|
1428
|
-
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
1429
|
-
...
|
|
1430
|
-
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
1431
|
-
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
1432
|
-
})()
|
|
1625
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
1626
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
1433
1627
|
];
|
|
1434
|
-
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
1628
|
+
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;
|
|
1435
1629
|
return sealCredential({
|
|
1436
1630
|
apiKey: winner.value,
|
|
1437
|
-
tier:
|
|
1438
|
-
source: winner.path,
|
|
1631
|
+
tier: winner.src.tier,
|
|
1632
|
+
source: winner.src.path,
|
|
1439
1633
|
deliberate: false,
|
|
1440
|
-
deprecated: false,
|
|
1441
1634
|
diskCandidates: diskPaths,
|
|
1442
1635
|
warning
|
|
1443
1636
|
});
|
|
1444
1637
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
assertUsableCredential(name, legacy.key, legacy.value);
|
|
1448
|
-
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
1449
|
-
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
1450
|
-
const sink = options.onDeprecation ?? defaultDeprecationSink;
|
|
1451
|
-
const notified = deprecationNotified();
|
|
1452
|
-
if (!notified.has(name)) {
|
|
1453
|
-
notified.add(name);
|
|
1454
|
-
sink(message);
|
|
1455
|
-
}
|
|
1638
|
+
if (envHit) {
|
|
1639
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
1456
1640
|
return sealCredential({
|
|
1457
|
-
apiKey:
|
|
1458
|
-
tier: "
|
|
1459
|
-
source:
|
|
1641
|
+
apiKey: envHit.value,
|
|
1642
|
+
tier: "env",
|
|
1643
|
+
source: envHit.key,
|
|
1460
1644
|
deliberate: false,
|
|
1461
|
-
deprecated: true,
|
|
1462
1645
|
diskCandidates: diskPaths,
|
|
1463
|
-
warning:
|
|
1646
|
+
warning: null
|
|
1464
1647
|
});
|
|
1465
1648
|
}
|
|
1466
1649
|
return null;
|
|
1467
1650
|
}
|
|
1651
|
+
var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
1652
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
1653
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
|
|
1654
|
+
var DEFAULT_AUTHORITY_SOURCE = "default";
|
|
1655
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
1656
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
1657
|
+
}
|
|
1468
1658
|
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
1469
1659
|
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1470
1660
|
function isValidDnsDomain(value) {
|
|
@@ -1473,21 +1663,11 @@ function isValidDnsDomain(value) {
|
|
|
1473
1663
|
}
|
|
1474
1664
|
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
1475
1665
|
}
|
|
1476
|
-
function
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
const value = raw?.trim();
|
|
1480
|
-
if (value)
|
|
1481
|
-
return { key, value: options.preserveRaw ? raw : value };
|
|
1482
|
-
}
|
|
1483
|
-
return null;
|
|
1484
|
-
}
|
|
1485
|
-
function firstEnvDefinedKey(env, keys) {
|
|
1486
|
-
for (const key of keys) {
|
|
1487
|
-
if (env[key] !== undefined)
|
|
1488
|
-
return key;
|
|
1666
|
+
function validateAppSlug(name) {
|
|
1667
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
1668
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
1489
1669
|
}
|
|
1490
|
-
return
|
|
1670
|
+
return name;
|
|
1491
1671
|
}
|
|
1492
1672
|
function rawAuthority(value) {
|
|
1493
1673
|
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
@@ -1590,140 +1770,102 @@ function toV1BaseUrl(apiUrl) {
|
|
|
1590
1770
|
url.pathname = `${path}/v1`;
|
|
1591
1771
|
return url.toString().replace(/\/+$/, "");
|
|
1592
1772
|
}
|
|
1593
|
-
|
|
1773
|
+
class ClientTransportConfigurationError extends Error {
|
|
1774
|
+
appName;
|
|
1775
|
+
sources;
|
|
1776
|
+
constructor(appName, message, sources = []) {
|
|
1777
|
+
super(message);
|
|
1778
|
+
this.name = "ClientTransportConfigurationError";
|
|
1779
|
+
this.appName = appName;
|
|
1780
|
+
this.sources = Object.freeze([...sources]);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
1784
|
+
env = snapshotClientEnvironment(name, env);
|
|
1594
1785
|
const keys = clientTransportEnvKeys(name);
|
|
1595
|
-
const
|
|
1596
|
-
const
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
const
|
|
1601
|
-
if (
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
apiUrlSource: null,
|
|
1624
|
-
apiKeyPresent: Boolean(keyHit),
|
|
1625
|
-
apiKeySource: keyHit ? keyHit.key : null,
|
|
1626
|
-
apiKeyTier: null,
|
|
1627
|
-
misconfigured: false,
|
|
1628
|
-
warning: null
|
|
1629
|
-
};
|
|
1786
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
1787
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
1788
|
+
if (blankUrl) {
|
|
1789
|
+
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]);
|
|
1790
|
+
}
|
|
1791
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
1792
|
+
if (controlledUrl) {
|
|
1793
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
1794
|
+
}
|
|
1795
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
1796
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
1797
|
+
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));
|
|
1798
|
+
}
|
|
1799
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
1800
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
1801
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
1802
|
+
if (diskConfigUrlHit?.unusable) {
|
|
1803
|
+
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]);
|
|
1804
|
+
}
|
|
1805
|
+
const urlCandidates = [
|
|
1806
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
1807
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
1808
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
1809
|
+
];
|
|
1810
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
1811
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
1812
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
1813
|
+
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));
|
|
1630
1814
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1815
|
+
const warnings = [];
|
|
1816
|
+
if (configuredUrl && !envUrlHit) {
|
|
1817
|
+
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.`);
|
|
1633
1818
|
}
|
|
1634
1819
|
const credential = resolveCredential(name, env, options.credentials);
|
|
1635
1820
|
if (!credential) {
|
|
1636
1821
|
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
transportSource: urlHit.key,
|
|
1641
|
-
baseUrl: null,
|
|
1642
|
-
apiUrlSource: urlHit.key,
|
|
1643
|
-
apiKeyPresent: false,
|
|
1644
|
-
apiKeySource: null,
|
|
1645
|
-
apiKeyTier: null,
|
|
1646
|
-
misconfigured: true,
|
|
1647
|
-
warning: warnings.join(" ")
|
|
1648
|
-
};
|
|
1822
|
+
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`;
|
|
1823
|
+
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.`);
|
|
1824
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
1649
1825
|
}
|
|
1650
1826
|
if (credential.warning)
|
|
1651
1827
|
warnings.push(credential.warning);
|
|
1828
|
+
let urlHit;
|
|
1829
|
+
if (configuredUrl) {
|
|
1830
|
+
urlHit = configuredUrl;
|
|
1831
|
+
} else {
|
|
1832
|
+
try {
|
|
1833
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1836
|
+
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]]);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1652
1839
|
const apiUrlSource = urlHit.key;
|
|
1653
1840
|
let baseUrl;
|
|
1654
1841
|
try {
|
|
1655
1842
|
baseUrl = toV1BaseUrl(urlHit.value);
|
|
1656
1843
|
} catch (error) {
|
|
1657
1844
|
const message = error instanceof Error ? error.message : String(error);
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1845
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
1846
|
+
}
|
|
1847
|
+
return {
|
|
1848
|
+
resolution: {
|
|
1849
|
+
transport: "http",
|
|
1661
1850
|
transportSource: urlHit.key,
|
|
1662
|
-
baseUrl
|
|
1663
|
-
apiUrlSource
|
|
1851
|
+
baseUrl,
|
|
1852
|
+
apiUrlSource,
|
|
1664
1853
|
apiKeyPresent: true,
|
|
1665
1854
|
apiKeySource: credential.source,
|
|
1666
1855
|
apiKeyTier: credential.tier,
|
|
1667
|
-
misconfigured:
|
|
1668
|
-
warning: warnings.join(" ")
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
return {
|
|
1672
|
-
transport: "http",
|
|
1673
|
-
transportSource: urlHit.key,
|
|
1674
|
-
baseUrl,
|
|
1675
|
-
apiUrlSource,
|
|
1676
|
-
apiKeyPresent: true,
|
|
1677
|
-
apiKeySource: credential.source,
|
|
1678
|
-
apiKeyTier: credential.tier,
|
|
1679
|
-
misconfigured: false,
|
|
1680
|
-
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1856
|
+
misconfigured: false,
|
|
1857
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1858
|
+
},
|
|
1859
|
+
credential
|
|
1681
1860
|
};
|
|
1682
1861
|
}
|
|
1862
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
1863
|
+
return resolveClientTransportSnapshot(name, env, options).resolution;
|
|
1864
|
+
}
|
|
1683
1865
|
function credentialDiskSourcesForMessage(name, env) {
|
|
1684
1866
|
const paths = credentialDiskSources(name, env);
|
|
1685
|
-
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
class HasnaHttpError extends Error {
|
|
1689
|
-
status;
|
|
1690
|
-
method;
|
|
1691
|
-
path;
|
|
1692
|
-
body;
|
|
1693
|
-
credentialSource;
|
|
1694
|
-
credentialTier;
|
|
1695
|
-
constructor(method, path, status, body, credential) {
|
|
1696
|
-
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
1697
|
-
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
1698
|
-
this.name = "HasnaHttpError";
|
|
1699
|
-
this.status = status;
|
|
1700
|
-
this.method = method;
|
|
1701
|
-
this.path = path;
|
|
1702
|
-
this.body = body;
|
|
1703
|
-
this.credentialSource = credential?.source ?? null;
|
|
1704
|
-
this.credentialTier = credential?.tier ?? null;
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
function currentCredential(name, apiKey) {
|
|
1708
|
-
if (typeof apiKey === "function") {
|
|
1709
|
-
return validateAndSealResolvedCredential(name, apiKey());
|
|
1710
|
-
}
|
|
1711
|
-
return explicitCredential(name, apiKey);
|
|
1712
|
-
}
|
|
1713
|
-
function authFailureGuidance(credential) {
|
|
1714
|
-
const origin = `The API key for this request came from ${credential.source}`;
|
|
1715
|
-
if (credential.deliberate) {
|
|
1716
|
-
const remedy = credential.source === CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE ? `Fix that provider so it returns the current key, or replace it with resolveCredential() ` + `so diagnostics can name the original source.` : `Rotate that key, or unset the override to use the credential on disk.`;
|
|
1717
|
-
return `${origin} \u2014 a credential you selected deliberately. It was NOT substituted with any other key: ` + `falling back here would authenticate as a different principal than the one you named, which is ` + `exactly the failure an override exists to prevent. ${remedy}`;
|
|
1718
|
-
}
|
|
1719
|
-
if (credential.deprecated) {
|
|
1720
|
-
const target = credential.diskCandidates[0];
|
|
1721
|
-
const remedy = target ? `Write the CURRENT key to ${target} \u2014 that file is re-read on every call, so rotations take ` + `effect immediately and in every shell. Do not simply unset ${credential.source}: nothing was ` + `found on disk, so that would leave this client with no credential at all.` : `This environment has no HOME, so no credential file could be consulted; the disk tier is ` + `unavailable here and there is nothing to fall back to. Set HOME, or supply the key explicitly.`;
|
|
1722
|
-
return `${origin}, a variable in this process's environment \u2014 which is a snapshot taken when the process ` + `started. A STALE SHELL is the most common cause of this error: this shell exported the key before ` + `it was rotated, and will keep sending the old one until it exits. ${remedy}`;
|
|
1723
|
-
}
|
|
1724
|
-
return `${origin}, which was re-read from disk on this very call \u2014 so a stale shell is NOT the cause here. ` + `The stored credential is genuinely being rejected: rotate it, or re-run the fleet key distribution ` + `so this machine gets the current key.`;
|
|
1867
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
1725
1868
|
}
|
|
1726
|
-
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
1727
1869
|
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
1728
1870
|
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
1729
1871
|
"host",
|
|
@@ -1732,215 +1874,32 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
|
1732
1874
|
"x-forwarded-host",
|
|
1733
1875
|
"x-original-host"
|
|
1734
1876
|
]);
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1877
|
+
|
|
1878
|
+
// ../contracts/dist/client/storage.js
|
|
1879
|
+
import { isIP as isIP2 } from "net";
|
|
1880
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1881
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync3 } from "fs";
|
|
1882
|
+
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|
|
1883
|
+
import { createRequire as createRequire2 } from "module";
|
|
1884
|
+
import { hostname as osHostname2 } from "os";
|
|
1885
|
+
import { isAbsolute as isAbsolute3, join as join4 } from "path";
|
|
1886
|
+
function envToken2(name) {
|
|
1887
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
1742
1888
|
}
|
|
1743
|
-
function
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
if (value === null || value === undefined)
|
|
1750
|
-
continue;
|
|
1751
|
-
if (Array.isArray(value)) {
|
|
1752
|
-
for (const v of value)
|
|
1753
|
-
params.append(key, String(v));
|
|
1754
|
-
} else {
|
|
1755
|
-
params.append(key, String(value));
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
const qs = params.toString();
|
|
1760
|
-
if (!qs)
|
|
1761
|
-
return path;
|
|
1762
|
-
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
1763
|
-
}
|
|
1764
|
-
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1765
|
-
function createHasnaHttpTransport(options) {
|
|
1766
|
-
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1767
|
-
const base = toV1BaseUrl(options.baseUrl);
|
|
1768
|
-
const timeoutMs = options.timeoutMs ?? 30000;
|
|
1769
|
-
const sleep = options.sleepImpl ?? defaultSleep;
|
|
1770
|
-
const defaultRetry = options.retry;
|
|
1771
|
-
function resolveRetry(callRetry) {
|
|
1772
|
-
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
1773
|
-
if (chosen === false)
|
|
1774
|
-
return null;
|
|
1775
|
-
const r = chosen ?? {};
|
|
1776
|
-
return {
|
|
1777
|
-
retries: r.retries ?? 2,
|
|
1778
|
-
baseDelayMs: r.baseDelayMs ?? 200,
|
|
1779
|
-
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
1780
|
-
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
1781
|
-
};
|
|
1782
|
-
}
|
|
1783
|
-
async function once(method, rel, url, body, opts, credential) {
|
|
1784
|
-
assertNoAuthorityOverrideHeaders(options.headers, "transport");
|
|
1785
|
-
assertNoAuthorityOverrideHeaders(opts.headers, "request");
|
|
1786
|
-
const headers = {
|
|
1787
|
-
"x-api-key": credential.apiKey,
|
|
1788
|
-
Authorization: `Bearer ${credential.apiKey}`,
|
|
1789
|
-
Accept: "application/json",
|
|
1790
|
-
...options.headers ?? {},
|
|
1791
|
-
...opts.headers ?? {}
|
|
1792
|
-
};
|
|
1793
|
-
if (opts.idempotencyKey)
|
|
1794
|
-
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
1795
|
-
const init = {
|
|
1796
|
-
method,
|
|
1797
|
-
headers,
|
|
1798
|
-
redirect: "manual"
|
|
1799
|
-
};
|
|
1800
|
-
if (body !== undefined) {
|
|
1801
|
-
headers["Content-Type"] = "application/json";
|
|
1802
|
-
init.body = JSON.stringify(body);
|
|
1803
|
-
}
|
|
1804
|
-
const controller = new AbortController;
|
|
1805
|
-
const onAbort = () => controller.abort();
|
|
1806
|
-
if (opts.signal) {
|
|
1807
|
-
if (opts.signal.aborted)
|
|
1808
|
-
controller.abort();
|
|
1809
|
-
else
|
|
1810
|
-
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
1811
|
-
}
|
|
1812
|
-
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
1813
|
-
init.signal = controller.signal;
|
|
1814
|
-
let response;
|
|
1815
|
-
try {
|
|
1816
|
-
response = await fetchImpl(url, init);
|
|
1817
|
-
} catch (error) {
|
|
1818
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
1819
|
-
if (opts.signal?.aborted)
|
|
1820
|
-
return { ok: false, retryable: false, error: err };
|
|
1821
|
-
return { ok: false, retryable: true, error: err };
|
|
1822
|
-
} finally {
|
|
1823
|
-
clearTimeout(timer);
|
|
1824
|
-
if (opts.signal)
|
|
1825
|
-
opts.signal.removeEventListener("abort", onAbort);
|
|
1826
|
-
}
|
|
1827
|
-
const text = await response.text();
|
|
1828
|
-
let parsed = undefined;
|
|
1829
|
-
if (text.length > 0) {
|
|
1830
|
-
try {
|
|
1831
|
-
parsed = JSON.parse(text);
|
|
1832
|
-
} catch {
|
|
1833
|
-
parsed = text;
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
|
-
if (!response.ok) {
|
|
1837
|
-
if (response.status >= 300 && response.status < 400) {
|
|
1838
|
-
return {
|
|
1839
|
-
ok: false,
|
|
1840
|
-
retryable: false,
|
|
1841
|
-
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
1842
|
-
};
|
|
1843
|
-
}
|
|
1844
|
-
if (response.status === 401 || response.status === 403) {
|
|
1845
|
-
return {
|
|
1846
|
-
ok: false,
|
|
1847
|
-
retryable: false,
|
|
1848
|
-
error: new HasnaHttpError(method, rel, response.status, parsed, {
|
|
1849
|
-
source: credential.source,
|
|
1850
|
-
tier: credential.tier,
|
|
1851
|
-
guidance: authFailureGuidance(credential)
|
|
1852
|
-
})
|
|
1853
|
-
};
|
|
1854
|
-
}
|
|
1855
|
-
const retry = resolveRetry(opts.retry);
|
|
1856
|
-
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
1857
|
-
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
1858
|
-
}
|
|
1859
|
-
return { ok: true, value: parsed };
|
|
1860
|
-
}
|
|
1861
|
-
async function request(method, path, body, opts = {}) {
|
|
1862
|
-
const upper = method.toUpperCase();
|
|
1863
|
-
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
1864
|
-
const url = `${base}${rel}`;
|
|
1865
|
-
const retry = resolveRetry(opts.retry);
|
|
1866
|
-
const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
|
|
1867
|
-
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
1868
|
-
const credential = currentCredential(options.name, options.apiKey);
|
|
1869
|
-
let last = null;
|
|
1870
|
-
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
1871
|
-
const result = await once(upper, rel, url, body, opts, credential);
|
|
1872
|
-
if (result.ok)
|
|
1873
|
-
return result.value;
|
|
1874
|
-
last = result;
|
|
1875
|
-
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
1876
|
-
if (!canRetry)
|
|
1877
|
-
break;
|
|
1878
|
-
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
1879
|
-
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
1880
|
-
await sleep(backoff + jitter);
|
|
1881
|
-
}
|
|
1882
|
-
throw last.error;
|
|
1883
|
-
}
|
|
1884
|
-
return {
|
|
1885
|
-
baseUrl: base,
|
|
1886
|
-
request,
|
|
1887
|
-
get: (path, opts) => request("GET", path, undefined, opts),
|
|
1888
|
-
post: (path, body, opts) => request("POST", path, body, opts),
|
|
1889
|
-
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
1890
|
-
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
1891
|
-
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
1892
|
-
};
|
|
1893
|
-
}
|
|
1894
|
-
function createClientTransport(name, env = process.env, overrides) {
|
|
1895
|
-
const credentialOptions = overrides?.credentials;
|
|
1896
|
-
const resolution = resolveClientTransport(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
|
|
1897
|
-
if (resolution.misconfigured) {
|
|
1898
|
-
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
|
|
1899
|
-
}
|
|
1900
|
-
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
1901
|
-
return { transport: "sqlite", client: null, resolution };
|
|
1902
|
-
}
|
|
1903
|
-
const credentialProvider = () => {
|
|
1904
|
-
const resolved = resolveCredential(name, env, credentialOptions);
|
|
1905
|
-
if (!resolved) {
|
|
1906
|
-
throw new Error(`Client for '${name}' resolved to the http transport but no API key is available any more. ` + `Looked at ${credentialDiskSourcesForMessage(name, env)}, then the environment. ` + `A credential file that was removed after this client was built is the usual cause.`);
|
|
1907
|
-
}
|
|
1908
|
-
return resolved;
|
|
1909
|
-
};
|
|
1910
|
-
return {
|
|
1911
|
-
transport: "http",
|
|
1912
|
-
client: createHasnaHttpTransport({
|
|
1913
|
-
name,
|
|
1914
|
-
baseUrl: resolution.baseUrl,
|
|
1915
|
-
apiKey: credentialProvider,
|
|
1916
|
-
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
1917
|
-
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
1918
|
-
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
1919
|
-
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
1920
|
-
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
1921
|
-
}),
|
|
1922
|
-
resolution
|
|
1923
|
-
};
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
// ../contracts/dist/client/storage.js
|
|
1927
|
-
import { isIP as isIP2 } from "net";
|
|
1928
|
-
import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
|
|
1929
|
-
import { join as join4 } from "path";
|
|
1930
|
-
function envToken2(name) {
|
|
1931
|
-
return name.toUpperCase().replace(/-/g, "_");
|
|
1932
|
-
}
|
|
1933
|
-
function clientTransportEnvKeys2(name) {
|
|
1934
|
-
const envSegment = envToken2(name);
|
|
1935
|
-
return {
|
|
1936
|
-
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
1937
|
-
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
1938
|
-
};
|
|
1889
|
+
function clientTransportEnvKeys2(name) {
|
|
1890
|
+
const envSegment = envToken2(name);
|
|
1891
|
+
return {
|
|
1892
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
1893
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
1894
|
+
};
|
|
1939
1895
|
}
|
|
1940
1896
|
function credentialOverrideEnvKey2(name) {
|
|
1941
1897
|
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
1942
1898
|
}
|
|
1943
1899
|
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
1900
|
+
function credentialPointerEnvKey2(name) {
|
|
1901
|
+
return `HASNA_${envToken2(name)}_API_KEY_REF`;
|
|
1902
|
+
}
|
|
1944
1903
|
|
|
1945
1904
|
class CredentialResolutionError2 extends Error {
|
|
1946
1905
|
appName;
|
|
@@ -1952,34 +1911,70 @@ class CredentialResolutionError2 extends Error {
|
|
|
1952
1911
|
this.attempted = attempted;
|
|
1953
1912
|
}
|
|
1954
1913
|
}
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1914
|
+
|
|
1915
|
+
class CredentialFileUnsafeError2 extends Error {
|
|
1916
|
+
path;
|
|
1917
|
+
constructor(path, reason) {
|
|
1918
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
1919
|
+
this.name = "CredentialFileUnsafeError";
|
|
1920
|
+
this.path = path;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
var HASNA_HOME_ENV_KEY2 = "HASNA_HOME";
|
|
1924
|
+
var HASNA_CONFIG_HOME_ENV_KEY2 = "HASNA_CONFIG_HOME";
|
|
1925
|
+
var KEYCHAIN_STATION_ENV_KEY2 = "HASNA_STATION";
|
|
1926
|
+
var HASNA_HOME_DIR2 = ".hasna";
|
|
1927
|
+
var CONFIG_SUBDIR2 = "config";
|
|
1928
|
+
var CREDENTIALS_FILE2 = "credentials";
|
|
1929
|
+
var KEYCHAIN_SECURITY_BIN2 = "/usr/bin/security";
|
|
1930
|
+
var KEYCHAIN_SERVICE_PREFIX2 = "hasna.credentials";
|
|
1931
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS2 = 44;
|
|
1932
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS2 = 1e4;
|
|
1959
1933
|
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
1960
1934
|
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1961
1935
|
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
1962
1936
|
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
1937
|
+
var VAULT_POINTER_SHAPE2 = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
1963
1938
|
function homeDir2(env) {
|
|
1964
1939
|
const home = env.HOME?.trim();
|
|
1965
1940
|
return home ? home : null;
|
|
1966
1941
|
}
|
|
1967
|
-
function
|
|
1968
|
-
|
|
1942
|
+
function absoluteOverride2(env, key) {
|
|
1943
|
+
const value = env[key]?.trim();
|
|
1944
|
+
return value && isAbsolute3(value) ? value : null;
|
|
1969
1945
|
}
|
|
1970
|
-
function
|
|
1946
|
+
function hasnaHomeDir2(env) {
|
|
1947
|
+
const override = absoluteOverride2(env, HASNA_HOME_ENV_KEY2);
|
|
1948
|
+
if (override)
|
|
1949
|
+
return override;
|
|
1971
1950
|
const home = homeDir2(env);
|
|
1972
|
-
|
|
1951
|
+
return home ? join4(home, HASNA_HOME_DIR2) : null;
|
|
1952
|
+
}
|
|
1953
|
+
function appConfigDir2(name, env) {
|
|
1954
|
+
const configRoot = absoluteOverride2(env, HASNA_CONFIG_HOME_ENV_KEY2);
|
|
1955
|
+
if (configRoot)
|
|
1956
|
+
return join4(configRoot, name);
|
|
1957
|
+
const root = hasnaHomeDir2(env);
|
|
1958
|
+
return root ? join4(root, name, CONFIG_SUBDIR2) : null;
|
|
1959
|
+
}
|
|
1960
|
+
function credentialDiskSourceList2(name, env, profile = null) {
|
|
1961
|
+
if (!SAFE_APP_SLUG2.test(name))
|
|
1973
1962
|
return [];
|
|
1974
|
-
const
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1963
|
+
const directory = appConfigDir2(name, env);
|
|
1964
|
+
if (!directory)
|
|
1965
|
+
return [];
|
|
1966
|
+
const file = profile ? `${CREDENTIALS_FILE2}-${profile}` : CREDENTIALS_FILE2;
|
|
1967
|
+
return [{ path: join4(directory, file), tier: "disk" }];
|
|
1968
|
+
}
|
|
1969
|
+
function credentialDiskSources2(name, env) {
|
|
1970
|
+
return credentialDiskSourceList2(name, env, null).map((s) => s.path);
|
|
1971
|
+
}
|
|
1972
|
+
function profileDiskSources2(name, env, profile) {
|
|
1973
|
+
return credentialDiskSourceList2(name, env, profile).map((s) => s.path);
|
|
1980
1974
|
}
|
|
1981
1975
|
function parseEnvFile2(text) {
|
|
1982
1976
|
const values = new Map;
|
|
1977
|
+
const unusable = new Set;
|
|
1983
1978
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
1984
1979
|
const line = rawLine.trim();
|
|
1985
1980
|
if (line.length === 0 || line.startsWith("#"))
|
|
@@ -1994,38 +1989,81 @@ function parseEnvFile2(text) {
|
|
|
1994
1989
|
let value = withoutExport.slice(equals + 1).trim();
|
|
1995
1990
|
const quote = value[0];
|
|
1996
1991
|
if (quote === '"' || quote === "'") {
|
|
1997
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
1992
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
1993
|
+
unusable.add(key);
|
|
1998
1994
|
continue;
|
|
1995
|
+
}
|
|
1999
1996
|
value = value.slice(1, -1);
|
|
2000
1997
|
}
|
|
2001
|
-
if (value.length === 0)
|
|
1998
|
+
if (value.trim().length === 0) {
|
|
1999
|
+
unusable.add(key);
|
|
2002
2000
|
continue;
|
|
2001
|
+
}
|
|
2002
|
+
if (values.has(key) && values.get(key) !== value)
|
|
2003
|
+
unusable.add(key);
|
|
2003
2004
|
values.set(key, value);
|
|
2004
2005
|
}
|
|
2005
|
-
return values;
|
|
2006
|
+
return { values, unusable };
|
|
2007
|
+
}
|
|
2008
|
+
function configFileModeAllowed2(mode) {
|
|
2009
|
+
const permissions = mode & 4095;
|
|
2010
|
+
return permissions === 256 || permissions === 384;
|
|
2011
|
+
}
|
|
2012
|
+
function configFileReadsCoherent2(before, after) {
|
|
2013
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
2006
2014
|
}
|
|
2007
2015
|
function readAppConfigFile2(path) {
|
|
2008
|
-
|
|
2016
|
+
const unsafe = (reason) => {
|
|
2017
|
+
throw new CredentialFileUnsafeError2(path, reason);
|
|
2018
|
+
};
|
|
2019
|
+
let fd = -1;
|
|
2009
2020
|
try {
|
|
2010
|
-
|
|
2011
|
-
|
|
2021
|
+
fd = openSync2(path, O_RDONLY2 | O_NOFOLLOW2 | O_NONBLOCK2);
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
const code = error.code;
|
|
2024
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
2012
2025
|
return null;
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2026
|
+
if (code === "ELOOP")
|
|
2027
|
+
unsafe("the path is a symlink");
|
|
2028
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
2029
|
+
}
|
|
2030
|
+
try {
|
|
2031
|
+
const before = fstatSync2(fd);
|
|
2032
|
+
if (!before.isFile())
|
|
2033
|
+
unsafe("the path is not a regular file");
|
|
2034
|
+
if (!configFileModeAllowed2(before.mode)) {
|
|
2035
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
2036
|
+
}
|
|
2037
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
2038
|
+
if (uid !== undefined && before.uid !== uid)
|
|
2039
|
+
unsafe("the file is not owned by the current user");
|
|
2040
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
2041
|
+
unsafe("the file exceeds the size limit");
|
|
2042
|
+
const bytes = readFileSync3(fd);
|
|
2043
|
+
const after = fstatSync2(fd);
|
|
2044
|
+
if (!configFileReadsCoherent2(before, after)) {
|
|
2045
|
+
unsafe("the file changed while being read");
|
|
2046
|
+
}
|
|
2047
|
+
return parseEnvFile2(bytes.toString("utf8"));
|
|
2048
|
+
} finally {
|
|
2049
|
+
if (fd !== -1)
|
|
2050
|
+
closeSync2(fd);
|
|
2016
2051
|
}
|
|
2017
|
-
return parseEnvFile2(text);
|
|
2018
2052
|
}
|
|
2019
2053
|
function readCredentialFile2(path, apiKeyKeys) {
|
|
2020
|
-
const
|
|
2021
|
-
if (!
|
|
2054
|
+
const parsed = readAppConfigFile2(path);
|
|
2055
|
+
if (!parsed)
|
|
2022
2056
|
return null;
|
|
2023
2057
|
for (const key of apiKeyKeys) {
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2058
|
+
if (parsed.unusable.has(key)) {
|
|
2059
|
+
throw new CredentialFileUnsafeError2(path, `${key} is declared but blank or malformed`);
|
|
2060
|
+
}
|
|
2027
2061
|
}
|
|
2028
|
-
|
|
2062
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2063
|
+
if (new Set(values).size > 1) {
|
|
2064
|
+
throw new CredentialFileUnsafeError2(path, "credential aliases disagree");
|
|
2065
|
+
}
|
|
2066
|
+
return values[0] ?? null;
|
|
2029
2067
|
}
|
|
2030
2068
|
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2031
2069
|
function appConfigDiskValue2(name, env, keys) {
|
|
@@ -2033,11 +2071,19 @@ function appConfigDiskValue2(name, env, keys) {
|
|
|
2033
2071
|
if (wanted.length === 0)
|
|
2034
2072
|
return null;
|
|
2035
2073
|
for (const path of credentialDiskSources2(name, env)) {
|
|
2036
|
-
const
|
|
2037
|
-
if (!
|
|
2074
|
+
const parsed = readAppConfigFile2(path);
|
|
2075
|
+
if (!parsed)
|
|
2038
2076
|
continue;
|
|
2077
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
2078
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
2079
|
+
}
|
|
2080
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2081
|
+
if (new Set(values).size > 1)
|
|
2082
|
+
throw new CredentialFileUnsafeError2(path, "configuration aliases disagree");
|
|
2039
2083
|
for (const key of wanted) {
|
|
2040
|
-
|
|
2084
|
+
if (parsed.unusable.has(key))
|
|
2085
|
+
return { key, value: "", path, unusable: true };
|
|
2086
|
+
const value = parsed.values.get(key)?.trim();
|
|
2041
2087
|
if (value)
|
|
2042
2088
|
return { key, value, path };
|
|
2043
2089
|
}
|
|
@@ -2045,20 +2091,22 @@ function appConfigDiskValue2(name, env, keys) {
|
|
|
2045
2091
|
return null;
|
|
2046
2092
|
}
|
|
2047
2093
|
function assertUsableCredential2(appName, source, value) {
|
|
2094
|
+
if (VAULT_POINTER_SHAPE2.test(value)) {
|
|
2095
|
+
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]);
|
|
2096
|
+
}
|
|
2048
2097
|
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
2049
2098
|
return;
|
|
2050
2099
|
throw new CredentialResolutionError2(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
2051
2100
|
}
|
|
2052
2101
|
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
2053
2102
|
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
2054
|
-
var
|
|
2103
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
2055
2104
|
function sealCredential2(fields) {
|
|
2056
2105
|
const { apiKey } = fields;
|
|
2057
2106
|
const visible = {
|
|
2058
2107
|
tier: fields.tier,
|
|
2059
2108
|
source: fields.source,
|
|
2060
2109
|
deliberate: fields.deliberate,
|
|
2061
|
-
deprecated: fields.deprecated,
|
|
2062
2110
|
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
2063
2111
|
warning: fields.warning
|
|
2064
2112
|
};
|
|
@@ -2069,6 +2117,14 @@ function sealCredential2(fields) {
|
|
|
2069
2117
|
writable: false,
|
|
2070
2118
|
configurable: false
|
|
2071
2119
|
});
|
|
2120
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
2121
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
2122
|
+
value: fields.pointerVaultKey,
|
|
2123
|
+
enumerable: false,
|
|
2124
|
+
writable: false,
|
|
2125
|
+
configurable: false
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2072
2128
|
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
2073
2129
|
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
2074
2130
|
enumerable: false,
|
|
@@ -2083,10 +2139,10 @@ function sealCredential2(fields) {
|
|
|
2083
2139
|
});
|
|
2084
2140
|
return Object.freeze(sealed);
|
|
2085
2141
|
}
|
|
2086
|
-
function
|
|
2142
|
+
function isSealedCredential(credential) {
|
|
2087
2143
|
return credential[CREDENTIAL_SEAL2] === true;
|
|
2088
2144
|
}
|
|
2089
|
-
function
|
|
2145
|
+
function explicitCredential(appName, apiKey) {
|
|
2090
2146
|
const source = "explicit apiKey option";
|
|
2091
2147
|
assertUsableCredential2(appName, source, apiKey);
|
|
2092
2148
|
return sealCredential2({
|
|
@@ -2094,21 +2150,19 @@ function explicitCredential2(appName, apiKey) {
|
|
|
2094
2150
|
tier: "argument",
|
|
2095
2151
|
source,
|
|
2096
2152
|
deliberate: true,
|
|
2097
|
-
deprecated: false,
|
|
2098
2153
|
diskCandidates: [],
|
|
2099
2154
|
warning: null
|
|
2100
2155
|
});
|
|
2101
2156
|
}
|
|
2102
|
-
function
|
|
2157
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
2103
2158
|
const apiKey = credential.apiKey;
|
|
2104
|
-
assertUsableCredential2(appName,
|
|
2105
|
-
if (!
|
|
2159
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
2160
|
+
if (!isSealedCredential(credential)) {
|
|
2106
2161
|
return sealCredential2({
|
|
2107
2162
|
apiKey,
|
|
2108
2163
|
tier: "argument",
|
|
2109
|
-
source:
|
|
2164
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
2110
2165
|
deliberate: true,
|
|
2111
|
-
deprecated: false,
|
|
2112
2166
|
diskCandidates: [],
|
|
2113
2167
|
warning: null
|
|
2114
2168
|
});
|
|
@@ -2118,53 +2172,147 @@ function validateAndSealResolvedCredential2(appName, credential) {
|
|
|
2118
2172
|
tier: credential.tier,
|
|
2119
2173
|
source: credential.source,
|
|
2120
2174
|
deliberate: credential.deliberate,
|
|
2121
|
-
deprecated: credential.deprecated,
|
|
2122
2175
|
diskCandidates: credential.diskCandidates,
|
|
2123
|
-
warning: credential.warning
|
|
2176
|
+
warning: credential.warning,
|
|
2177
|
+
...credential.pointerVaultKey !== undefined ? { pointerVaultKey: credential.pointerVaultKey } : {}
|
|
2124
2178
|
});
|
|
2125
2179
|
}
|
|
2126
2180
|
function firstEnvValue2(env, keys) {
|
|
2127
2181
|
for (const key of keys) {
|
|
2182
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
2183
|
+
continue;
|
|
2128
2184
|
const value = env[key]?.trim();
|
|
2129
2185
|
if (value)
|
|
2130
2186
|
return { key, value };
|
|
2131
2187
|
}
|
|
2132
2188
|
return null;
|
|
2133
2189
|
}
|
|
2134
|
-
var
|
|
2135
|
-
function
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2190
|
+
var AMBIENT_ENVIRONMENT2 = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
2191
|
+
function isAmbientEnvironment2(env) {
|
|
2192
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT2] === true;
|
|
2193
|
+
}
|
|
2194
|
+
function defaultKeychainRunner2(argv) {
|
|
2195
|
+
const result = spawnSync3(KEYCHAIN_SECURITY_BIN2, [...argv], {
|
|
2196
|
+
encoding: "utf8",
|
|
2197
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2198
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS2
|
|
2199
|
+
});
|
|
2200
|
+
return {
|
|
2201
|
+
status: result.status,
|
|
2202
|
+
stdout: result.stdout ?? "",
|
|
2203
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
function keychainTierEnabled2(env, options) {
|
|
2207
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
2208
|
+
return false;
|
|
2209
|
+
if (options.enabled !== undefined)
|
|
2210
|
+
return options.enabled;
|
|
2211
|
+
return options.run !== undefined || isAmbientEnvironment2(env);
|
|
2212
|
+
}
|
|
2213
|
+
function keychainAccount2(env, options) {
|
|
2214
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY2]?.trim();
|
|
2215
|
+
if (station)
|
|
2216
|
+
return station;
|
|
2217
|
+
const host = (options.hostname ?? osHostname2)().split(".")[0]?.trim() ?? "";
|
|
2218
|
+
if (host)
|
|
2219
|
+
return host;
|
|
2220
|
+
const user = env.USER?.trim();
|
|
2221
|
+
return user || null;
|
|
2222
|
+
}
|
|
2223
|
+
function keychainFailureHint2(text) {
|
|
2224
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
2225
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
2226
|
+
return clean ? `: ${clean}` : "";
|
|
2227
|
+
}
|
|
2228
|
+
function readKeychainItem2(name, env, kind, options) {
|
|
2229
|
+
if (!SAFE_APP_SLUG2.test(name) || !keychainTierEnabled2(env, options))
|
|
2230
|
+
return null;
|
|
2231
|
+
const account = keychainAccount2(env, options);
|
|
2232
|
+
if (!account)
|
|
2233
|
+
return null;
|
|
2234
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX2}.${name}.${kind}`;
|
|
2235
|
+
const source = `keychain:${service}@${account}`;
|
|
2236
|
+
const run = options.run ?? defaultKeychainRunner2;
|
|
2237
|
+
let result;
|
|
2238
|
+
try {
|
|
2239
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
2240
|
+
} catch (error) {
|
|
2241
|
+
const reason = keychainFailureHint2(error instanceof Error ? error.message : String(error));
|
|
2242
|
+
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]);
|
|
2243
|
+
}
|
|
2244
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS2)
|
|
2245
|
+
return null;
|
|
2246
|
+
if (result.status !== 0) {
|
|
2247
|
+
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]);
|
|
2248
|
+
}
|
|
2249
|
+
const value = result.stdout.trim();
|
|
2250
|
+
if (!value) {
|
|
2251
|
+
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]);
|
|
2252
|
+
}
|
|
2253
|
+
return { value, source };
|
|
2254
|
+
}
|
|
2255
|
+
function keychainConfigValue2(name, env, options = {}) {
|
|
2256
|
+
return readKeychainItem2(name, env, "api-url", options);
|
|
2143
2257
|
}
|
|
2144
|
-
function
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2258
|
+
function snapshotClientEnvironment2(name, env) {
|
|
2259
|
+
const keys = clientTransportEnvKeys2(name);
|
|
2260
|
+
const ambient = isAmbientEnvironment2(env);
|
|
2261
|
+
const snapshot = Object.create(null);
|
|
2262
|
+
for (const key of [
|
|
2263
|
+
...keys.apiUrlKeys,
|
|
2264
|
+
...keys.apiKeyKeys,
|
|
2265
|
+
credentialOverrideEnvKey2(name),
|
|
2266
|
+
credentialPointerEnvKey2(name),
|
|
2267
|
+
CREDENTIAL_PROFILE_ENV_KEY2,
|
|
2268
|
+
"HOME",
|
|
2269
|
+
HASNA_HOME_ENV_KEY2,
|
|
2270
|
+
HASNA_CONFIG_HOME_ENV_KEY2,
|
|
2271
|
+
KEYCHAIN_STATION_ENV_KEY2,
|
|
2272
|
+
"USER"
|
|
2273
|
+
]) {
|
|
2274
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
2275
|
+
if (!descriptor)
|
|
2276
|
+
continue;
|
|
2277
|
+
if (!("value" in descriptor)) {
|
|
2278
|
+
throw new CredentialResolutionError2(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
2279
|
+
}
|
|
2280
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
2281
|
+
throw new CredentialResolutionError2(name, `${key} must be a string data property.`, [key]);
|
|
2282
|
+
}
|
|
2283
|
+
snapshot[key] = descriptor.value;
|
|
2148
2284
|
}
|
|
2285
|
+
if (ambient) {
|
|
2286
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT2, {
|
|
2287
|
+
value: true,
|
|
2288
|
+
enumerable: false,
|
|
2289
|
+
writable: false,
|
|
2290
|
+
configurable: false
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
return Object.freeze(snapshot);
|
|
2149
2294
|
}
|
|
2150
2295
|
function resolveCredential2(name, env, options = {}) {
|
|
2296
|
+
env = snapshotClientEnvironment2(name, env);
|
|
2151
2297
|
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
2152
2298
|
const diskPaths = credentialDiskSources2(name, env);
|
|
2153
|
-
|
|
2154
|
-
|
|
2299
|
+
if (options.apiKey !== undefined) {
|
|
2300
|
+
const explicitKey = options.apiKey.trim();
|
|
2301
|
+
if (!explicitKey) {
|
|
2302
|
+
throw new CredentialResolutionError2(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
2303
|
+
}
|
|
2155
2304
|
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
2156
2305
|
return sealCredential2({
|
|
2157
2306
|
apiKey: explicitKey,
|
|
2158
2307
|
tier: "argument",
|
|
2159
2308
|
source: "explicit apiKey argument",
|
|
2160
2309
|
deliberate: true,
|
|
2161
|
-
deprecated: false,
|
|
2162
2310
|
diskCandidates: diskPaths,
|
|
2163
2311
|
warning: null
|
|
2164
2312
|
});
|
|
2165
2313
|
}
|
|
2166
2314
|
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
2167
|
-
const overrideRaw = env[overrideKeyName];
|
|
2315
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
2168
2316
|
if (overrideRaw !== undefined) {
|
|
2169
2317
|
const override = overrideRaw.trim();
|
|
2170
2318
|
if (!override) {
|
|
@@ -2176,12 +2324,38 @@ function resolveCredential2(name, env, options = {}) {
|
|
|
2176
2324
|
tier: "override",
|
|
2177
2325
|
source: overrideKeyName,
|
|
2178
2326
|
deliberate: true,
|
|
2179
|
-
deprecated: false,
|
|
2180
2327
|
diskCandidates: diskPaths,
|
|
2181
2328
|
warning: null
|
|
2182
2329
|
});
|
|
2183
2330
|
}
|
|
2184
|
-
const
|
|
2331
|
+
const pointerKeyName = credentialPointerEnvKey2(name);
|
|
2332
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
2333
|
+
if (pointerRaw !== undefined) {
|
|
2334
|
+
const pointer = pointerRaw.trim();
|
|
2335
|
+
if (!pointer) {
|
|
2336
|
+
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]);
|
|
2337
|
+
}
|
|
2338
|
+
if (!VAULT_POINTER_SHAPE2.test(pointer)) {
|
|
2339
|
+
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]);
|
|
2340
|
+
}
|
|
2341
|
+
return sealCredential2({
|
|
2342
|
+
apiKey: "",
|
|
2343
|
+
pointerVaultKey: pointer,
|
|
2344
|
+
tier: "pointer",
|
|
2345
|
+
source: pointerKeyName,
|
|
2346
|
+
deliberate: true,
|
|
2347
|
+
diskCandidates: diskPaths,
|
|
2348
|
+
warning: null
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
2352
|
+
throw new CredentialResolutionError2(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
2353
|
+
}
|
|
2354
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY2) ? env[CREDENTIAL_PROFILE_ENV_KEY2] : undefined;
|
|
2355
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
2356
|
+
throw new CredentialResolutionError2(name, `${CREDENTIAL_PROFILE_ENV_KEY2} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY2]);
|
|
2357
|
+
}
|
|
2358
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
2185
2359
|
if (profile) {
|
|
2186
2360
|
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
2187
2361
|
if (!SAFE_PROFILE2.test(profile)) {
|
|
@@ -2197,7 +2371,6 @@ function resolveCredential2(name, env, options = {}) {
|
|
|
2197
2371
|
tier: "profile",
|
|
2198
2372
|
source: path,
|
|
2199
2373
|
deliberate: true,
|
|
2200
|
-
deprecated: false,
|
|
2201
2374
|
diskCandidates: paths,
|
|
2202
2375
|
warning: null
|
|
2203
2376
|
});
|
|
@@ -2205,51 +2378,105 @@ function resolveCredential2(name, env, options = {}) {
|
|
|
2205
2378
|
}
|
|
2206
2379
|
throw new CredentialResolutionError2(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY2}.`, paths);
|
|
2207
2380
|
}
|
|
2208
|
-
const
|
|
2381
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
2382
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
2383
|
+
if (blankEnv) {
|
|
2384
|
+
throw new CredentialResolutionError2(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
2385
|
+
}
|
|
2386
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
2387
|
+
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));
|
|
2388
|
+
}
|
|
2389
|
+
const envHit = firstEnvValue2(env, apiKeyKeys);
|
|
2390
|
+
const keychainHit = readKeychainItem2(name, env, "api-key", options.keychain ?? {});
|
|
2391
|
+
if (keychainHit) {
|
|
2392
|
+
assertUsableCredential2(name, keychainHit.source, keychainHit.value);
|
|
2393
|
+
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;
|
|
2394
|
+
return sealCredential2({
|
|
2395
|
+
apiKey: keychainHit.value,
|
|
2396
|
+
tier: "keychain",
|
|
2397
|
+
source: keychainHit.source,
|
|
2398
|
+
deliberate: false,
|
|
2399
|
+
diskCandidates: diskPaths,
|
|
2400
|
+
warning
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
const diskSourceList = credentialDiskSourceList2(name, env, null);
|
|
2404
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile2(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
2209
2405
|
if (diskHits.length > 0) {
|
|
2210
2406
|
const winner = diskHits[0];
|
|
2211
|
-
assertUsableCredential2(name, winner.path, winner.value);
|
|
2407
|
+
assertUsableCredential2(name, winner.src.path, winner.value);
|
|
2212
2408
|
const divergentSources = [
|
|
2213
|
-
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
2214
|
-
...
|
|
2215
|
-
const legacyHit = firstEnvValue2(env, apiKeyKeys);
|
|
2216
|
-
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
2217
|
-
})()
|
|
2409
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
2410
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
2218
2411
|
];
|
|
2219
|
-
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
2412
|
+
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;
|
|
2220
2413
|
return sealCredential2({
|
|
2221
2414
|
apiKey: winner.value,
|
|
2222
|
-
tier:
|
|
2223
|
-
source: winner.path,
|
|
2415
|
+
tier: winner.src.tier,
|
|
2416
|
+
source: winner.src.path,
|
|
2224
2417
|
deliberate: false,
|
|
2225
|
-
deprecated: false,
|
|
2226
2418
|
diskCandidates: diskPaths,
|
|
2227
2419
|
warning
|
|
2228
2420
|
});
|
|
2229
2421
|
}
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
assertUsableCredential2(name, legacy.key, legacy.value);
|
|
2233
|
-
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
2234
|
-
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
2235
|
-
const sink = options.onDeprecation ?? defaultDeprecationSink2;
|
|
2236
|
-
const notified = deprecationNotified2();
|
|
2237
|
-
if (!notified.has(name)) {
|
|
2238
|
-
notified.add(name);
|
|
2239
|
-
sink(message);
|
|
2240
|
-
}
|
|
2422
|
+
if (envHit) {
|
|
2423
|
+
assertUsableCredential2(name, envHit.key, envHit.value);
|
|
2241
2424
|
return sealCredential2({
|
|
2242
|
-
apiKey:
|
|
2243
|
-
tier: "
|
|
2244
|
-
source:
|
|
2425
|
+
apiKey: envHit.value,
|
|
2426
|
+
tier: "env",
|
|
2427
|
+
source: envHit.key,
|
|
2245
2428
|
deliberate: false,
|
|
2246
|
-
deprecated: true,
|
|
2247
2429
|
diskCandidates: diskPaths,
|
|
2248
|
-
warning:
|
|
2430
|
+
warning: null
|
|
2249
2431
|
});
|
|
2250
2432
|
}
|
|
2251
2433
|
return null;
|
|
2252
2434
|
}
|
|
2435
|
+
var SECRETS_PACKAGE_SPECIFIER2 = "@hasna/" + "secrets";
|
|
2436
|
+
var requireSecretsSdk2 = createRequire2(import.meta.url);
|
|
2437
|
+
async function completePointerCredential(name, pointerResolution, env = process.env) {
|
|
2438
|
+
const vaultKey = pointerResolution.pointerVaultKey;
|
|
2439
|
+
const pointerEnvKey = pointerResolution.source;
|
|
2440
|
+
if (!vaultKey) {
|
|
2441
|
+
throw new CredentialResolutionError2(name, `Pointer resolution from ${pointerEnvKey} carries no vault item key; this is a defect in the resolver.`, [pointerEnvKey]);
|
|
2442
|
+
}
|
|
2443
|
+
let secretsSdk;
|
|
2444
|
+
try {
|
|
2445
|
+
secretsSdk = requireSecretsSdk2(SECRETS_PACKAGE_SPECIFIER2);
|
|
2446
|
+
} catch {
|
|
2447
|
+
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]);
|
|
2448
|
+
}
|
|
2449
|
+
let client;
|
|
2450
|
+
try {
|
|
2451
|
+
client = secretsSdk.createSecretsClientFromEnv(env);
|
|
2452
|
+
} catch {
|
|
2453
|
+
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]);
|
|
2454
|
+
}
|
|
2455
|
+
let secret;
|
|
2456
|
+
try {
|
|
2457
|
+
secret = await client.getSecret({ key: vaultKey });
|
|
2458
|
+
} catch {
|
|
2459
|
+
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]);
|
|
2460
|
+
}
|
|
2461
|
+
const value = secret.value;
|
|
2462
|
+
if (!value) {
|
|
2463
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} resolved vault item '${vaultKey}', but it holds no value. A vault pointer is TERMINAL.`, [pointerEnvKey]);
|
|
2464
|
+
}
|
|
2465
|
+
assertUsableCredential2(name, `${pointerEnvKey} -> vault:${vaultKey}`, value);
|
|
2466
|
+
return sealCredential2({
|
|
2467
|
+
apiKey: value,
|
|
2468
|
+
tier: "pointer",
|
|
2469
|
+
source: `${pointerEnvKey} -> vault:${vaultKey}`,
|
|
2470
|
+
deliberate: true,
|
|
2471
|
+
diskCandidates: pointerResolution.diskCandidates,
|
|
2472
|
+
warning: null
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN2 = "https://api.hasna.com";
|
|
2476
|
+
var DEFAULT_AUTHORITY_SOURCE2 = "default";
|
|
2477
|
+
function defaultFleetGatewayBaseUrl2(name) {
|
|
2478
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN2}/${validateAppSlug2(name)}`;
|
|
2479
|
+
}
|
|
2253
2480
|
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
2254
2481
|
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2255
2482
|
function isValidDnsDomain2(value) {
|
|
@@ -2258,21 +2485,11 @@ function isValidDnsDomain2(value) {
|
|
|
2258
2485
|
}
|
|
2259
2486
|
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
2260
2487
|
}
|
|
2261
|
-
function
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
const value = raw?.trim();
|
|
2265
|
-
if (value)
|
|
2266
|
-
return { key, value: options.preserveRaw ? raw : value };
|
|
2488
|
+
function validateAppSlug2(name) {
|
|
2489
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN2.test(name)) {
|
|
2490
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
2267
2491
|
}
|
|
2268
|
-
return
|
|
2269
|
-
}
|
|
2270
|
-
function firstEnvDefinedKey2(env, keys) {
|
|
2271
|
-
for (const key of keys) {
|
|
2272
|
-
if (env[key] !== undefined)
|
|
2273
|
-
return key;
|
|
2274
|
-
}
|
|
2275
|
-
return null;
|
|
2492
|
+
return name;
|
|
2276
2493
|
}
|
|
2277
2494
|
function rawAuthority2(value) {
|
|
2278
2495
|
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
@@ -2375,106 +2592,104 @@ function toV1BaseUrl2(apiUrl) {
|
|
|
2375
2592
|
url.pathname = `${path}/v1`;
|
|
2376
2593
|
return url.toString().replace(/\/+$/, "");
|
|
2377
2594
|
}
|
|
2378
|
-
|
|
2595
|
+
class ClientTransportConfigurationError2 extends Error {
|
|
2596
|
+
appName;
|
|
2597
|
+
sources;
|
|
2598
|
+
constructor(appName, message, sources = []) {
|
|
2599
|
+
super(message);
|
|
2600
|
+
this.name = "ClientTransportConfigurationError";
|
|
2601
|
+
this.appName = appName;
|
|
2602
|
+
this.sources = Object.freeze([...sources]);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
function resolveClientTransportSnapshot2(name, env = process.env, options = {}) {
|
|
2606
|
+
env = snapshotClientEnvironment2(name, env);
|
|
2379
2607
|
const keys = clientTransportEnvKeys2(name);
|
|
2380
|
-
const
|
|
2381
|
-
const
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
const
|
|
2386
|
-
if (
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
apiUrlSource: null,
|
|
2409
|
-
apiKeyPresent: Boolean(keyHit),
|
|
2410
|
-
apiKeySource: keyHit ? keyHit.key : null,
|
|
2411
|
-
apiKeyTier: null,
|
|
2412
|
-
misconfigured: false,
|
|
2413
|
-
warning: null
|
|
2414
|
-
};
|
|
2608
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
2609
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
2610
|
+
if (blankUrl) {
|
|
2611
|
+
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]);
|
|
2612
|
+
}
|
|
2613
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN2.test(entry.raw));
|
|
2614
|
+
if (controlledUrl) {
|
|
2615
|
+
throw new ClientTransportConfigurationError2(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
2616
|
+
}
|
|
2617
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
2618
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
2619
|
+
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));
|
|
2620
|
+
}
|
|
2621
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
2622
|
+
const keychainUrlHit = keychainConfigValue2(name, env, options.credentials?.keychain);
|
|
2623
|
+
const diskConfigUrlHit = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
2624
|
+
if (diskConfigUrlHit?.unusable) {
|
|
2625
|
+
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]);
|
|
2626
|
+
}
|
|
2627
|
+
const urlCandidates = [
|
|
2628
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
2629
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
2630
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
2631
|
+
];
|
|
2632
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
2633
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
2634
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
2635
|
+
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));
|
|
2415
2636
|
}
|
|
2416
|
-
|
|
2417
|
-
|
|
2637
|
+
const warnings = [];
|
|
2638
|
+
if (configuredUrl && !envUrlHit) {
|
|
2639
|
+
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.`);
|
|
2418
2640
|
}
|
|
2419
2641
|
const credential = resolveCredential2(name, env, options.credentials);
|
|
2420
2642
|
if (!credential) {
|
|
2421
2643
|
const diskHint = credentialDiskSourcesForMessage2(name, env);
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
transportSource: urlHit.key,
|
|
2426
|
-
baseUrl: null,
|
|
2427
|
-
apiUrlSource: urlHit.key,
|
|
2428
|
-
apiKeyPresent: false,
|
|
2429
|
-
apiKeySource: null,
|
|
2430
|
-
apiKeyTier: null,
|
|
2431
|
-
misconfigured: true,
|
|
2432
|
-
warning: warnings.join(" ")
|
|
2433
|
-
};
|
|
2644
|
+
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`;
|
|
2645
|
+
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.`);
|
|
2646
|
+
throw new ClientTransportConfigurationError2(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
2434
2647
|
}
|
|
2435
2648
|
if (credential.warning)
|
|
2436
2649
|
warnings.push(credential.warning);
|
|
2650
|
+
let urlHit;
|
|
2651
|
+
if (configuredUrl) {
|
|
2652
|
+
urlHit = configuredUrl;
|
|
2653
|
+
} else {
|
|
2654
|
+
try {
|
|
2655
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE2, value: defaultFleetGatewayBaseUrl2(name) };
|
|
2656
|
+
} catch (error) {
|
|
2657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2658
|
+
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]]);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2437
2661
|
const apiUrlSource = urlHit.key;
|
|
2438
2662
|
let baseUrl;
|
|
2439
2663
|
try {
|
|
2440
2664
|
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
2441
2665
|
} catch (error) {
|
|
2442
2666
|
const message = error instanceof Error ? error.message : String(error);
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2667
|
+
throw new ClientTransportConfigurationError2(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
2668
|
+
}
|
|
2669
|
+
return {
|
|
2670
|
+
resolution: {
|
|
2671
|
+
transport: "http",
|
|
2446
2672
|
transportSource: urlHit.key,
|
|
2447
|
-
baseUrl
|
|
2448
|
-
apiUrlSource
|
|
2673
|
+
baseUrl,
|
|
2674
|
+
apiUrlSource,
|
|
2449
2675
|
apiKeyPresent: true,
|
|
2450
2676
|
apiKeySource: credential.source,
|
|
2451
2677
|
apiKeyTier: credential.tier,
|
|
2452
|
-
misconfigured:
|
|
2453
|
-
warning: warnings.join(" ")
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
return {
|
|
2457
|
-
transport: "http",
|
|
2458
|
-
transportSource: urlHit.key,
|
|
2459
|
-
baseUrl,
|
|
2460
|
-
apiUrlSource,
|
|
2461
|
-
apiKeyPresent: true,
|
|
2462
|
-
apiKeySource: credential.source,
|
|
2463
|
-
apiKeyTier: credential.tier,
|
|
2464
|
-
misconfigured: false,
|
|
2465
|
-
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2678
|
+
misconfigured: false,
|
|
2679
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2680
|
+
},
|
|
2681
|
+
credential
|
|
2466
2682
|
};
|
|
2467
2683
|
}
|
|
2468
2684
|
function credentialDiskSourcesForMessage2(name, env) {
|
|
2469
2685
|
const paths = credentialDiskSources2(name, env);
|
|
2470
|
-
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
2686
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
2471
2687
|
}
|
|
2472
2688
|
|
|
2473
|
-
class
|
|
2689
|
+
class HasnaHttpError extends Error {
|
|
2474
2690
|
status;
|
|
2475
2691
|
method;
|
|
2476
2692
|
path;
|
|
2477
|
-
body;
|
|
2478
2693
|
credentialSource;
|
|
2479
2694
|
credentialTier;
|
|
2480
2695
|
constructor(method, path, status, body, credential) {
|
|
@@ -2484,31 +2699,46 @@ class HasnaHttpError2 extends Error {
|
|
|
2484
2699
|
this.status = status;
|
|
2485
2700
|
this.method = method;
|
|
2486
2701
|
this.path = path;
|
|
2487
|
-
this
|
|
2702
|
+
Object.defineProperty(this, "body", {
|
|
2703
|
+
value: body,
|
|
2704
|
+
enumerable: status !== 401 && status !== 403,
|
|
2705
|
+
writable: false,
|
|
2706
|
+
configurable: false
|
|
2707
|
+
});
|
|
2488
2708
|
this.credentialSource = credential?.source ?? null;
|
|
2489
2709
|
this.credentialTier = credential?.tier ?? null;
|
|
2490
2710
|
}
|
|
2491
2711
|
}
|
|
2492
|
-
function
|
|
2712
|
+
function currentCredential(name, apiKey) {
|
|
2493
2713
|
if (typeof apiKey === "function") {
|
|
2494
|
-
return
|
|
2714
|
+
return validateAndSealResolvedCredential(name, apiKey());
|
|
2715
|
+
}
|
|
2716
|
+
return explicitCredential(name, apiKey);
|
|
2717
|
+
}
|
|
2718
|
+
async function resolveRequestCredential(name, apiKey, env = process.env) {
|
|
2719
|
+
const resolved = currentCredential(name, apiKey);
|
|
2720
|
+
if (resolved.tier === "pointer") {
|
|
2721
|
+
return completePointerCredential(name, resolved, env);
|
|
2495
2722
|
}
|
|
2496
|
-
return
|
|
2723
|
+
return resolved;
|
|
2497
2724
|
}
|
|
2498
|
-
function
|
|
2725
|
+
function authFailureGuidance(credential) {
|
|
2499
2726
|
const origin = `The API key for this request came from ${credential.source}`;
|
|
2500
2727
|
if (credential.deliberate) {
|
|
2501
|
-
const remedy = credential.source ===
|
|
2728
|
+
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.`;
|
|
2502
2729
|
return `${origin} \u2014 a credential you selected deliberately. It was NOT substituted with any other key: ` + `falling back here would authenticate as a different principal than the one you named, which is ` + `exactly the failure an override exists to prevent. ${remedy}`;
|
|
2503
2730
|
}
|
|
2504
|
-
if (credential.
|
|
2731
|
+
if (credential.tier === "env") {
|
|
2505
2732
|
const target = credential.diskCandidates[0];
|
|
2506
|
-
const remedy = target ? `
|
|
2507
|
-
return `${origin}, a variable in this process's environment
|
|
2733
|
+
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.`;
|
|
2734
|
+
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}`;
|
|
2735
|
+
}
|
|
2736
|
+
if (credential.tier === "keychain") {
|
|
2737
|
+
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.`;
|
|
2508
2738
|
}
|
|
2509
2739
|
return `${origin}, which was re-read from disk on this very call \u2014 so a stale shell is NOT the cause here. ` + `The stored credential is genuinely being rejected: rotate it, or re-run the fleet key distribution ` + `so this machine gets the current key.`;
|
|
2510
2740
|
}
|
|
2511
|
-
var
|
|
2741
|
+
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
2512
2742
|
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2513
2743
|
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2514
2744
|
"host",
|
|
@@ -2517,7 +2747,7 @@ var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
|
2517
2747
|
"x-forwarded-host",
|
|
2518
2748
|
"x-original-host"
|
|
2519
2749
|
]);
|
|
2520
|
-
function
|
|
2750
|
+
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
2521
2751
|
if (!headers)
|
|
2522
2752
|
return;
|
|
2523
2753
|
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
@@ -2525,7 +2755,7 @@ function assertNoAuthorityOverrideHeaders2(headers, source) {
|
|
|
2525
2755
|
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
2526
2756
|
}
|
|
2527
2757
|
}
|
|
2528
|
-
function
|
|
2758
|
+
function appendQuery(path, query) {
|
|
2529
2759
|
if (!query)
|
|
2530
2760
|
return path;
|
|
2531
2761
|
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
@@ -2546,12 +2776,12 @@ function appendQuery2(path, query) {
|
|
|
2546
2776
|
return path;
|
|
2547
2777
|
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
2548
2778
|
}
|
|
2549
|
-
var
|
|
2550
|
-
function
|
|
2779
|
+
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2780
|
+
function createHasnaHttpTransportInternal(options, requestBindingProvider) {
|
|
2551
2781
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2552
2782
|
const base = toV1BaseUrl2(options.baseUrl);
|
|
2553
2783
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2554
|
-
const sleep = options.sleepImpl ??
|
|
2784
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
2555
2785
|
const defaultRetry = options.retry;
|
|
2556
2786
|
function resolveRetry(callRetry) {
|
|
2557
2787
|
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
@@ -2562,12 +2792,12 @@ function createHasnaHttpTransport2(options) {
|
|
|
2562
2792
|
retries: r.retries ?? 2,
|
|
2563
2793
|
baseDelayMs: r.baseDelayMs ?? 200,
|
|
2564
2794
|
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
2565
|
-
retryStatuses: r.retryStatuses ?? [...
|
|
2795
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
2566
2796
|
};
|
|
2567
2797
|
}
|
|
2568
2798
|
async function once(method, rel, url, body, opts, credential) {
|
|
2569
|
-
|
|
2570
|
-
|
|
2799
|
+
assertNoAuthorityOverrideHeaders(options.headers, "transport");
|
|
2800
|
+
assertNoAuthorityOverrideHeaders(opts.headers, "request");
|
|
2571
2801
|
const headers = {
|
|
2572
2802
|
"x-api-key": credential.apiKey,
|
|
2573
2803
|
Authorization: `Bearer ${credential.apiKey}`,
|
|
@@ -2609,13 +2839,20 @@ function createHasnaHttpTransport2(options) {
|
|
|
2609
2839
|
if (opts.signal)
|
|
2610
2840
|
opts.signal.removeEventListener("abort", onAbort);
|
|
2611
2841
|
}
|
|
2612
|
-
const
|
|
2842
|
+
const authenticationFailure = response.status === 401 || response.status === 403;
|
|
2613
2843
|
let parsed = undefined;
|
|
2614
|
-
if (
|
|
2844
|
+
if (authenticationFailure) {
|
|
2615
2845
|
try {
|
|
2616
|
-
|
|
2617
|
-
} catch {
|
|
2618
|
-
|
|
2846
|
+
await response.body?.cancel();
|
|
2847
|
+
} catch {}
|
|
2848
|
+
} else {
|
|
2849
|
+
const text = await response.text();
|
|
2850
|
+
if (text.length > 0) {
|
|
2851
|
+
try {
|
|
2852
|
+
parsed = JSON.parse(text);
|
|
2853
|
+
} catch {
|
|
2854
|
+
parsed = text;
|
|
2855
|
+
}
|
|
2619
2856
|
}
|
|
2620
2857
|
}
|
|
2621
2858
|
if (!response.ok) {
|
|
@@ -2623,34 +2860,38 @@ function createHasnaHttpTransport2(options) {
|
|
|
2623
2860
|
return {
|
|
2624
2861
|
ok: false,
|
|
2625
2862
|
retryable: false,
|
|
2626
|
-
error: new
|
|
2863
|
+
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
2627
2864
|
};
|
|
2628
2865
|
}
|
|
2629
|
-
if (
|
|
2866
|
+
if (authenticationFailure) {
|
|
2630
2867
|
return {
|
|
2631
2868
|
ok: false,
|
|
2632
2869
|
retryable: false,
|
|
2633
|
-
error: new
|
|
2870
|
+
error: new HasnaHttpError(method, rel, response.status, undefined, {
|
|
2634
2871
|
source: credential.source,
|
|
2635
2872
|
tier: credential.tier,
|
|
2636
|
-
guidance:
|
|
2873
|
+
guidance: authFailureGuidance(credential)
|
|
2637
2874
|
})
|
|
2638
2875
|
};
|
|
2639
2876
|
}
|
|
2640
2877
|
const retry = resolveRetry(opts.retry);
|
|
2641
2878
|
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
2642
|
-
return { ok: false, retryable, error: new
|
|
2879
|
+
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
2643
2880
|
}
|
|
2644
2881
|
return { ok: true, value: parsed };
|
|
2645
2882
|
}
|
|
2646
2883
|
async function request(method, path, body, opts = {}) {
|
|
2647
2884
|
const upper = method.toUpperCase();
|
|
2648
|
-
const rel =
|
|
2649
|
-
const url = `${base}${rel}`;
|
|
2885
|
+
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2650
2886
|
const retry = resolveRetry(opts.retry);
|
|
2651
2887
|
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
2652
2888
|
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2653
|
-
const
|
|
2889
|
+
const binding = requestBindingProvider ? await requestBindingProvider() : {
|
|
2890
|
+
baseUrl: base,
|
|
2891
|
+
credential: await resolveRequestCredential(options.name, options.apiKey)
|
|
2892
|
+
};
|
|
2893
|
+
const url = `${binding.baseUrl}${rel}`;
|
|
2894
|
+
const credential = binding.credential;
|
|
2654
2895
|
let last = null;
|
|
2655
2896
|
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2656
2897
|
const result = await once(upper, rel, url, body, opts, credential);
|
|
@@ -2676,34 +2917,43 @@ function createHasnaHttpTransport2(options) {
|
|
|
2676
2917
|
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
2677
2918
|
};
|
|
2678
2919
|
}
|
|
2679
|
-
function
|
|
2920
|
+
function createClientTransport(name, env = process.env, overrides) {
|
|
2680
2921
|
const credentialOptions = overrides?.credentials;
|
|
2681
|
-
const
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
if (
|
|
2691
|
-
throw new
|
|
2692
|
-
}
|
|
2693
|
-
|
|
2922
|
+
const snapshotOptions = { ...credentialOptions ? { credentials: credentialOptions } : {} };
|
|
2923
|
+
const resolution = resolveClientTransportSnapshot2(name, env, snapshotOptions).resolution;
|
|
2924
|
+
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;
|
|
2925
|
+
const unstableConfiguration = () => new ClientTransportConfigurationError2(name, "The configured service authority or credential changed while a request was being prepared; no authenticated request was sent.");
|
|
2926
|
+
const requestBindingProvider = async () => {
|
|
2927
|
+
const first = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
2928
|
+
const reviewed = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
2929
|
+
if (!sameBinding(first, reviewed))
|
|
2930
|
+
throw unstableConfiguration();
|
|
2931
|
+
if (reviewed.resolution.baseUrl !== resolution.baseUrl) {
|
|
2932
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
2933
|
+
}
|
|
2934
|
+
const credential = await resolveRequestCredential(name, () => reviewed.credential, env);
|
|
2935
|
+
const immediatelyBeforeDispatch = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
2936
|
+
if (!sameBinding(reviewed, immediatelyBeforeDispatch))
|
|
2937
|
+
throw unstableConfiguration();
|
|
2938
|
+
if (immediatelyBeforeDispatch.resolution.baseUrl !== resolution.baseUrl) {
|
|
2939
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
2940
|
+
}
|
|
2941
|
+
return { baseUrl: immediatelyBeforeDispatch.resolution.baseUrl, credential };
|
|
2694
2942
|
};
|
|
2695
2943
|
return {
|
|
2696
2944
|
transport: "http",
|
|
2697
|
-
client:
|
|
2945
|
+
client: createHasnaHttpTransportInternal({
|
|
2698
2946
|
name,
|
|
2699
2947
|
baseUrl: resolution.baseUrl,
|
|
2700
|
-
apiKey:
|
|
2948
|
+
apiKey: () => {
|
|
2949
|
+
throw new Error("The authenticated request binding provider was not invoked.");
|
|
2950
|
+
},
|
|
2701
2951
|
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2702
2952
|
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2703
2953
|
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2704
2954
|
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2705
2955
|
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2706
|
-
}),
|
|
2956
|
+
}, requestBindingProvider),
|
|
2707
2957
|
resolution
|
|
2708
2958
|
};
|
|
2709
2959
|
}
|
|
@@ -2807,41 +3057,68 @@ function createHasnaStorageClient(name, transport) {
|
|
|
2807
3057
|
};
|
|
2808
3058
|
}
|
|
2809
3059
|
function resolveStorageClient(name, env = process.env, overrides) {
|
|
2810
|
-
const wired =
|
|
2811
|
-
|
|
2812
|
-
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
2813
|
-
}
|
|
2814
|
-
return { transport: "sqlite", client: null };
|
|
3060
|
+
const wired = createClientTransport(name, env, overrides);
|
|
3061
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
2815
3062
|
}
|
|
2816
3063
|
|
|
2817
|
-
// src/
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
return
|
|
2824
|
-
storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
|
|
2825
|
-
apiUrlKeys: [`HASNA_${token}_API_URL`],
|
|
2826
|
-
apiKeyKeys: [`HASNA_${token}_API_KEY`]
|
|
2827
|
-
};
|
|
3064
|
+
// src/lib/local-opt-in.ts
|
|
3065
|
+
var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
|
|
3066
|
+
"HASNA_RECORDINGS_LOCAL",
|
|
3067
|
+
"RECORDINGS_LOCAL"
|
|
3068
|
+
];
|
|
3069
|
+
function isRecordingsLocalOptIn(env = process.env) {
|
|
3070
|
+
return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
2828
3071
|
}
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
3072
|
+
var RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
|
|
3073
|
+
"RECORDINGS_API_URL",
|
|
3074
|
+
"RECORDINGS_API_KEY"
|
|
3075
|
+
];
|
|
3076
|
+
function recordingsAuthorityEnvKeys() {
|
|
3077
|
+
const keys = clientTransportEnvKeys("recordings");
|
|
3078
|
+
return [
|
|
3079
|
+
...keys.apiUrlKeys,
|
|
3080
|
+
...keys.apiKeyKeys,
|
|
3081
|
+
credentialOverrideEnvKey("recordings"),
|
|
3082
|
+
credentialPointerEnvKey("recordings"),
|
|
3083
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
3084
|
+
].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
|
|
3085
|
+
}
|
|
3086
|
+
function hasRecordingsEnvAuthorityIntent(env = process.env) {
|
|
3087
|
+
return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
3088
|
+
}
|
|
3089
|
+
function selectsRecordingsLocalStore(env = process.env) {
|
|
3090
|
+
return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
|
|
3091
|
+
}
|
|
3092
|
+
function recordingsResolverEnv(env) {
|
|
3093
|
+
const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
|
|
3094
|
+
const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
3095
|
+
const removed = new Set([...carved, ...blanks]);
|
|
3096
|
+
if (removed.size === 0)
|
|
3097
|
+
return env;
|
|
3098
|
+
const next = { ...env };
|
|
3099
|
+
for (const key of removed)
|
|
3100
|
+
delete next[key];
|
|
3101
|
+
return next;
|
|
3102
|
+
}
|
|
3103
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
3104
|
+
function isAmbientRecordingsEnv(env) {
|
|
3105
|
+
if (typeof process !== "undefined" && env === process.env) {
|
|
3106
|
+
return true;
|
|
3107
|
+
}
|
|
3108
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
2836
3109
|
}
|
|
2837
|
-
function
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
3110
|
+
function recordingsResolverInputs(env, credentials = {}) {
|
|
3111
|
+
const normalised = recordingsResolverEnv(env);
|
|
3112
|
+
if (normalised === env)
|
|
3113
|
+
return { env: normalised, credentials };
|
|
3114
|
+
const keychain = { ...credentials.keychain };
|
|
3115
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
3116
|
+
keychain.enabled = isAmbientRecordingsEnv(env);
|
|
2842
3117
|
}
|
|
2843
|
-
return
|
|
3118
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
2844
3119
|
}
|
|
3120
|
+
|
|
3121
|
+
// src/http/client.ts
|
|
2845
3122
|
function toV1BaseUrl3(apiUrl) {
|
|
2846
3123
|
const url = new URL(apiUrl);
|
|
2847
3124
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -2855,69 +3132,8 @@ function toV1BaseUrl3(apiUrl) {
|
|
|
2855
3132
|
url.hash = "";
|
|
2856
3133
|
return url.toString().replace(/\/+$/, "");
|
|
2857
3134
|
}
|
|
2858
|
-
function resolveTransport(name, env = process.env) {
|
|
2859
|
-
const keys = envKeys(name);
|
|
2860
|
-
const storeHit = firstEnv3(env, keys.storeKeys);
|
|
2861
|
-
const urlHit = firstEnv3(env, keys.apiUrlKeys);
|
|
2862
|
-
const keyHit = firstEnv3(env, keys.apiKeyKeys);
|
|
2863
|
-
let requested = "sqlite";
|
|
2864
|
-
let modeSource = "default";
|
|
2865
|
-
if (storeHit) {
|
|
2866
|
-
requested = normalizeClientStore(storeHit.value);
|
|
2867
|
-
modeSource = storeHit.key;
|
|
2868
|
-
} else if (urlHit && keyHit) {
|
|
2869
|
-
requested = "http";
|
|
2870
|
-
modeSource = "auto:api-url+api-key";
|
|
2871
|
-
} else if (urlHit || keyHit) {
|
|
2872
|
-
const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
|
|
2873
|
-
const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
|
|
2874
|
-
return {
|
|
2875
|
-
transport: "sqlite",
|
|
2876
|
-
requested,
|
|
2877
|
-
modeSource,
|
|
2878
|
-
baseUrl: null,
|
|
2879
|
-
apiKeyPresent: Boolean(keyHit),
|
|
2880
|
-
misconfigured: true,
|
|
2881
|
-
warning: `${present} is set but ${missing} is not: the hosted API is only ` + `selected when BOTH are present. Set ${missing}, or unset ${present} to ` + `use the on-box store.`
|
|
2882
|
-
};
|
|
2883
|
-
}
|
|
2884
|
-
if (requested === "sqlite") {
|
|
2885
|
-
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
2886
|
-
}
|
|
2887
|
-
if (!urlHit) {
|
|
2888
|
-
return {
|
|
2889
|
-
transport: "sqlite",
|
|
2890
|
-
requested,
|
|
2891
|
-
modeSource,
|
|
2892
|
-
baseUrl: null,
|
|
2893
|
-
apiKeyPresent: Boolean(keyHit),
|
|
2894
|
-
misconfigured: true,
|
|
2895
|
-
warning: `${modeSource}=http but no API URL is set (${keys.apiUrlKeys[0]}). Refusing to route to the API.`
|
|
2896
|
-
};
|
|
2897
|
-
}
|
|
2898
|
-
if (!keyHit) {
|
|
2899
|
-
return {
|
|
2900
|
-
transport: "sqlite",
|
|
2901
|
-
requested,
|
|
2902
|
-
modeSource,
|
|
2903
|
-
baseUrl: null,
|
|
2904
|
-
apiKeyPresent: false,
|
|
2905
|
-
misconfigured: true,
|
|
2906
|
-
warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
|
|
2907
|
-
};
|
|
2908
|
-
}
|
|
2909
|
-
const rawUrl = urlHit.value;
|
|
2910
|
-
let baseUrl;
|
|
2911
|
-
try {
|
|
2912
|
-
baseUrl = toV1BaseUrl3(rawUrl);
|
|
2913
|
-
} catch (error) {
|
|
2914
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2915
|
-
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
|
|
2916
|
-
}
|
|
2917
|
-
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
2918
|
-
}
|
|
2919
3135
|
|
|
2920
|
-
class
|
|
3136
|
+
class HasnaHttpError2 extends Error {
|
|
2921
3137
|
status;
|
|
2922
3138
|
method;
|
|
2923
3139
|
path;
|
|
@@ -2931,7 +3147,7 @@ class HasnaHttpError3 extends Error {
|
|
|
2931
3147
|
this.body = body;
|
|
2932
3148
|
}
|
|
2933
3149
|
}
|
|
2934
|
-
function
|
|
3150
|
+
function appendQuery2(path, query) {
|
|
2935
3151
|
if (!query)
|
|
2936
3152
|
return path;
|
|
2937
3153
|
const params = new URLSearchParams;
|
|
@@ -2949,12 +3165,12 @@ function appendQuery3(path, query) {
|
|
|
2949
3165
|
}
|
|
2950
3166
|
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
2951
3167
|
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2952
|
-
var
|
|
3168
|
+
var defaultSleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2953
3169
|
function createHttpTransport(options) {
|
|
2954
3170
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2955
3171
|
const base = options.baseUrl.replace(/\/+$/, "");
|
|
2956
3172
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2957
|
-
const sleep = options.sleepImpl ??
|
|
3173
|
+
const sleep = options.sleepImpl ?? defaultSleep2;
|
|
2958
3174
|
async function once(method, rel, url, body, opts) {
|
|
2959
3175
|
const headers = {
|
|
2960
3176
|
"x-api-key": options.apiKey,
|
|
@@ -3002,13 +3218,13 @@ function createHttpTransport(options) {
|
|
|
3002
3218
|
}
|
|
3003
3219
|
}
|
|
3004
3220
|
if (!response.ok) {
|
|
3005
|
-
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new
|
|
3221
|
+
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new HasnaHttpError2(method, rel, response.status, parsed) };
|
|
3006
3222
|
}
|
|
3007
3223
|
return { ok: true, value: parsed };
|
|
3008
3224
|
}
|
|
3009
3225
|
async function request(method, path, body, opts = {}) {
|
|
3010
3226
|
const upper = method.toUpperCase();
|
|
3011
|
-
const rel =
|
|
3227
|
+
const rel = appendQuery2(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
3012
3228
|
const url = `${base}${rel}`;
|
|
3013
3229
|
const methodRetryable = IDEMPOTENT.has(upper) || Boolean(opts.idempotencyKey);
|
|
3014
3230
|
const maxRetries = opts.retries ?? 2;
|
|
@@ -3073,7 +3289,7 @@ function createStorageClient(name, transport) {
|
|
|
3073
3289
|
try {
|
|
3074
3290
|
return await transport.get(ep(resource, id));
|
|
3075
3291
|
} catch (error) {
|
|
3076
|
-
if (error instanceof
|
|
3292
|
+
if (error instanceof HasnaHttpError2 && error.status === 404)
|
|
3077
3293
|
return null;
|
|
3078
3294
|
throw error;
|
|
3079
3295
|
}
|
|
@@ -3089,46 +3305,278 @@ function createStorageClient(name, transport) {
|
|
|
3089
3305
|
try {
|
|
3090
3306
|
await transport.del(ep(resource, id));
|
|
3091
3307
|
} catch (error) {
|
|
3092
|
-
if (error instanceof
|
|
3308
|
+
if (error instanceof HasnaHttpError2 && error.status === 404)
|
|
3093
3309
|
return;
|
|
3094
3310
|
throw error;
|
|
3095
3311
|
}
|
|
3096
3312
|
}
|
|
3097
3313
|
};
|
|
3098
3314
|
}
|
|
3099
|
-
function
|
|
3100
|
-
const
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3315
|
+
function throwRecordingsAuthorityFailure(error) {
|
|
3316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3317
|
+
const name = error instanceof Error ? error.name : "";
|
|
3318
|
+
if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
|
|
3319
|
+
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 });
|
|
3320
|
+
}
|
|
3321
|
+
if (/no API key could be resolved/.test(message)) {
|
|
3322
|
+
if (/is not set and no API key could be resolved/.test(message)) {
|
|
3323
|
+
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 });
|
|
3324
|
+
}
|
|
3325
|
+
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 });
|
|
3326
|
+
}
|
|
3327
|
+
throw new Error(`REMOTE_API_URL_INVALID: ${message} local SQLite fallback is disabled`, { cause: error });
|
|
3328
|
+
}
|
|
3329
|
+
function toRecordsAuthority(resolution) {
|
|
3330
|
+
return {
|
|
3331
|
+
transport: resolution.transport,
|
|
3332
|
+
transportSource: resolution.transportSource,
|
|
3333
|
+
baseUrl: resolution.baseUrl,
|
|
3334
|
+
apiUrlSource: resolution.apiUrlSource,
|
|
3335
|
+
apiKeyPresent: resolution.apiKeyPresent,
|
|
3336
|
+
apiKeySource: resolution.apiKeySource,
|
|
3337
|
+
apiKeyTier: resolution.apiKeyTier,
|
|
3338
|
+
misconfigured: resolution.misconfigured,
|
|
3339
|
+
warning: resolution.warning
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
function resolveRecordingsTransport(env = process.env, options = {}) {
|
|
3343
|
+
if (selectsRecordingsLocalStore(env)) {
|
|
3344
|
+
return { transport: "sqlite", selected: false, source: "local-opt-in", authority: null };
|
|
3345
|
+
}
|
|
3346
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
3347
|
+
let resolution;
|
|
3348
|
+
try {
|
|
3349
|
+
resolution = resolveClientTransport("recordings", resolverInputs.env, {
|
|
3350
|
+
credentials: resolverInputs.credentials
|
|
3351
|
+
});
|
|
3352
|
+
} catch (error) {
|
|
3353
|
+
throwRecordingsAuthorityFailure(error);
|
|
3354
|
+
}
|
|
3355
|
+
return {
|
|
3356
|
+
transport: "http",
|
|
3357
|
+
selected: true,
|
|
3358
|
+
source: `${resolution.apiKeySource ?? resolution.apiKeyTier}+${resolution.apiUrlSource ?? "default"}`,
|
|
3359
|
+
authority: toRecordsAuthority(resolution)
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
function getRecordingsTransportStatus(env = process.env, options = {}) {
|
|
3363
|
+
let resolution;
|
|
3364
|
+
try {
|
|
3365
|
+
resolution = resolveRecordingsTransport(env, options);
|
|
3366
|
+
} catch (error) {
|
|
3367
|
+
const issue = error instanceof Error ? error.message : String(error);
|
|
3368
|
+
const envKeys = clientTransportEnvKeys("recordings");
|
|
3369
|
+
const declared = (keys) => keys.some((key) => (env[key] ?? "").trim() !== "");
|
|
3370
|
+
return {
|
|
3371
|
+
selected: true,
|
|
3372
|
+
ok: false,
|
|
3373
|
+
transport: "invalid",
|
|
3374
|
+
api_url_configured: declared(envKeys.apiUrlKeys),
|
|
3375
|
+
api_key_configured: declared(envKeys.apiKeyKeys),
|
|
3376
|
+
api_url_source: null,
|
|
3377
|
+
api_key_source: null,
|
|
3378
|
+
api_key_tier: null,
|
|
3379
|
+
v1_base_url: null,
|
|
3380
|
+
issues: [issue],
|
|
3381
|
+
local_fallback: false
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
if (!resolution.selected) {
|
|
3385
|
+
return {
|
|
3386
|
+
selected: false,
|
|
3387
|
+
ok: true,
|
|
3388
|
+
transport: resolution.transport,
|
|
3389
|
+
api_url_configured: false,
|
|
3390
|
+
api_key_configured: false,
|
|
3391
|
+
api_url_source: null,
|
|
3392
|
+
api_key_source: null,
|
|
3393
|
+
api_key_tier: null,
|
|
3394
|
+
v1_base_url: null,
|
|
3395
|
+
issues: [],
|
|
3396
|
+
local_fallback: false
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
3399
|
+
const authority = resolution.authority;
|
|
3400
|
+
return {
|
|
3401
|
+
selected: true,
|
|
3402
|
+
ok: true,
|
|
3403
|
+
transport: resolution.transport,
|
|
3404
|
+
api_url_configured: authority.apiUrlSource !== null && authority.apiUrlSource !== "default",
|
|
3405
|
+
api_key_configured: authority.apiKeyPresent,
|
|
3406
|
+
api_url_source: authority.apiUrlSource,
|
|
3407
|
+
api_key_source: authority.apiKeySource,
|
|
3408
|
+
api_key_tier: authority.apiKeyTier,
|
|
3409
|
+
v1_base_url: authority.baseUrl,
|
|
3410
|
+
issues: [],
|
|
3411
|
+
local_fallback: false
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
function resolveRecordingsCloudClient(env = process.env, options = {}) {
|
|
3415
|
+
if (selectsRecordingsLocalStore(env))
|
|
3416
|
+
return null;
|
|
3417
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
3418
|
+
let resolved;
|
|
3419
|
+
try {
|
|
3420
|
+
resolved = resolveStorageClient("recordings", resolverInputs.env, {
|
|
3421
|
+
fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
|
|
3422
|
+
credentials: resolverInputs.credentials
|
|
3423
|
+
});
|
|
3424
|
+
} catch (error) {
|
|
3425
|
+
throwRecordingsAuthorityFailure(error);
|
|
3426
|
+
}
|
|
3427
|
+
const client = resolved.client;
|
|
3428
|
+
return client;
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
// src/store.ts
|
|
3432
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
3433
|
+
|
|
3434
|
+
// src/lib/audio-artifact-storage.ts
|
|
3435
|
+
import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
|
|
3436
|
+
import { createHash } from "crypto";
|
|
3437
|
+
import { readFile } from "fs/promises";
|
|
3438
|
+
import { extname } from "path";
|
|
3439
|
+
var SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
3440
|
+
var DEFAULT_S3_PREFIX = "recordings";
|
|
3441
|
+
var AUDIO_CONTENT_TYPES = {
|
|
3442
|
+
wav: "audio/wav",
|
|
3443
|
+
mp3: "audio/mpeg",
|
|
3444
|
+
m4a: "audio/mp4",
|
|
3445
|
+
aac: "audio/aac",
|
|
3446
|
+
webm: "audio/webm",
|
|
3447
|
+
ogg: "audio/ogg",
|
|
3448
|
+
flac: "audio/flac"
|
|
3449
|
+
};
|
|
3450
|
+
function contentTypeForExtension(extension) {
|
|
3451
|
+
return AUDIO_CONTENT_TYPES[extension.toLowerCase()] ?? "application/octet-stream";
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
class AudioArtifactStorage {
|
|
3455
|
+
bucket;
|
|
3456
|
+
prefix;
|
|
3457
|
+
s3;
|
|
3458
|
+
constructor(options = {}) {
|
|
3459
|
+
this.bucket = options.bucket;
|
|
3460
|
+
this.prefix = (options.prefix || DEFAULT_S3_PREFIX).replace(/^\/+|\/+$/g, "");
|
|
3461
|
+
this.s3 = this.bucket ? options.client ?? new AwsS3Client({ region: options.region || process.env.AWS_REGION || "us-east-1" }) : undefined;
|
|
3462
|
+
}
|
|
3463
|
+
get usesS3() {
|
|
3464
|
+
return Boolean(this.bucket);
|
|
3465
|
+
}
|
|
3466
|
+
objectKeyFor(recordingId, sha256, extension) {
|
|
3467
|
+
assertSha256(sha256);
|
|
3468
|
+
const safeExtension = extension.toLowerCase().match(/^[a-z0-9]+/)?.[0] || "bin";
|
|
3469
|
+
return `${this.prefix}/${recordingId}/${sha256}.${safeExtension}`;
|
|
3470
|
+
}
|
|
3471
|
+
async uploadAudio(recordingId, audioPath) {
|
|
3472
|
+
if (!this.bucket || !this.s3)
|
|
3473
|
+
return null;
|
|
3474
|
+
const bytes = await readFileBytes(audioPath);
|
|
3475
|
+
if (!bytes)
|
|
3476
|
+
return null;
|
|
3477
|
+
const sha256 = sha256Hex(bytes);
|
|
3478
|
+
const extension = extensionOf(audioPath);
|
|
3479
|
+
const key = this.objectKeyFor(recordingId, sha256, extension);
|
|
3480
|
+
await this.s3.send(new PutObjectCommand({
|
|
3481
|
+
Bucket: this.bucket,
|
|
3482
|
+
Key: key,
|
|
3483
|
+
Body: bytes,
|
|
3484
|
+
ContentType: contentTypeForExtension(extension)
|
|
3485
|
+
}));
|
|
3486
|
+
return { objectKey: key, sha256, bytes: bytes.byteLength };
|
|
3487
|
+
}
|
|
3488
|
+
async readAudio(objectKey) {
|
|
3489
|
+
if (!this.bucket || !this.s3)
|
|
3490
|
+
return null;
|
|
3491
|
+
try {
|
|
3492
|
+
const response = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
|
3493
|
+
return await bodyToBytes(response.Body);
|
|
3494
|
+
} catch {
|
|
3495
|
+
return null;
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
function resolveAudioArtifactStorage(env = process.env, client) {
|
|
3500
|
+
const bucket = env.HASNA_RECORDINGS_S3_BUCKET || env.RECORDINGS_S3_BUCKET || undefined;
|
|
3501
|
+
const prefix = env.HASNA_RECORDINGS_S3_PREFIX || env.RECORDINGS_S3_PREFIX || undefined;
|
|
3502
|
+
const region = env.RECORDINGS_S3_REGION || env.AWS_REGION || undefined;
|
|
3503
|
+
return new AudioArtifactStorage({ bucket, prefix, region, client });
|
|
3504
|
+
}
|
|
3505
|
+
async function uploadAudioAtCreation(recordingId, audioPath, storage = resolveAudioArtifactStorage()) {
|
|
3506
|
+
if (!audioPath)
|
|
3507
|
+
return null;
|
|
3508
|
+
if (!storage.usesS3)
|
|
3509
|
+
return null;
|
|
3510
|
+
try {
|
|
3511
|
+
const uploaded = await storage.uploadAudio(recordingId, audioPath);
|
|
3512
|
+
if (!uploaded)
|
|
3513
|
+
return null;
|
|
3514
|
+
console.warn(`recordings: uploaded audio for ${recordingId} to ${uploaded.objectKey}`);
|
|
3515
|
+
return uploaded;
|
|
3516
|
+
} catch (error) {
|
|
3517
|
+
console.warn(`recordings: audio upload failed for ${recordingId} (${String(error instanceof Error ? error.message : error)}); keeping local audio_path only`);
|
|
3518
|
+
return null;
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
function assertSha256(value) {
|
|
3522
|
+
if (!SHA256_HEX.test(value)) {
|
|
3523
|
+
throw new Error("audio digest must be a lowercase hex sha-256; refusing to build a storage key from it");
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
function sha256Hex(bytes) {
|
|
3527
|
+
const hash = createHash("sha256");
|
|
3528
|
+
hash.update(bytes);
|
|
3529
|
+
return hash.digest("hex");
|
|
3530
|
+
}
|
|
3531
|
+
async function readFileBytes(path) {
|
|
3532
|
+
try {
|
|
3533
|
+
return new Uint8Array(await readFile(path));
|
|
3534
|
+
} catch {
|
|
3535
|
+
return null;
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
function extensionOf(path) {
|
|
3539
|
+
const extension = extname(path).replace(/^\./, "").toLowerCase();
|
|
3540
|
+
return extension || "bin";
|
|
3541
|
+
}
|
|
3542
|
+
async function bodyToBytes(body) {
|
|
3543
|
+
if (!body)
|
|
3544
|
+
return new Uint8Array(0);
|
|
3545
|
+
if (body instanceof Uint8Array)
|
|
3546
|
+
return body;
|
|
3547
|
+
if (typeof body.transformToByteArray === "function") {
|
|
3548
|
+
return new Uint8Array(await body.transformToByteArray());
|
|
3549
|
+
}
|
|
3550
|
+
if (typeof body.getReader === "function") {
|
|
3551
|
+
const reader = body.getReader();
|
|
3552
|
+
const chunks2 = [];
|
|
3553
|
+
for (;; ) {
|
|
3554
|
+
const { value, done } = await reader.read();
|
|
3555
|
+
if (done)
|
|
3556
|
+
break;
|
|
3557
|
+
if (value)
|
|
3558
|
+
chunks2.push(value);
|
|
3117
3559
|
}
|
|
3118
|
-
|
|
3560
|
+
return concat(chunks2);
|
|
3119
3561
|
}
|
|
3120
|
-
|
|
3121
|
-
|
|
3562
|
+
const chunks = [];
|
|
3563
|
+
for await (const chunk of body) {
|
|
3564
|
+
chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
3122
3565
|
}
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3566
|
+
return concat(chunks);
|
|
3567
|
+
}
|
|
3568
|
+
function concat(chunks) {
|
|
3569
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
3570
|
+
const merged = new Uint8Array(new ArrayBuffer(total));
|
|
3571
|
+
let offset = 0;
|
|
3572
|
+
for (const chunk of chunks) {
|
|
3573
|
+
merged.set(chunk, offset);
|
|
3574
|
+
offset += chunk.byteLength;
|
|
3126
3575
|
}
|
|
3127
|
-
return
|
|
3576
|
+
return merged;
|
|
3128
3577
|
}
|
|
3129
3578
|
|
|
3130
3579
|
// src/store.ts
|
|
3131
|
-
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
3132
3580
|
var APP = "recordings";
|
|
3133
3581
|
function listQuery(filter) {
|
|
3134
3582
|
if (!filter)
|
|
@@ -3159,7 +3607,15 @@ var localStore = {
|
|
|
3159
3607
|
mode: "sqlite",
|
|
3160
3608
|
baseUrl: null,
|
|
3161
3609
|
async createRecording(input, idempotencyKey) {
|
|
3162
|
-
return withLocalStoreReaderLease(() =>
|
|
3610
|
+
return withLocalStoreReaderLease(async () => {
|
|
3611
|
+
const recording = createRecording(input, undefined, idempotencyKey);
|
|
3612
|
+
if (!recording.audio_path)
|
|
3613
|
+
return recording;
|
|
3614
|
+
const uploaded = await uploadAudioAtCreation(recording.id, recording.audio_path, localArtifactStorageFor());
|
|
3615
|
+
if (!uploaded)
|
|
3616
|
+
return recording;
|
|
3617
|
+
return updateRecordingAudio(recording.id, uploaded.objectKey, uploaded.sha256, uploaded.bytes) ?? recording;
|
|
3618
|
+
});
|
|
3163
3619
|
},
|
|
3164
3620
|
async getRecording(id) {
|
|
3165
3621
|
return withLocalStoreReaderLease(() => getRecording(id));
|
|
@@ -3353,7 +3809,7 @@ function apiStore(client) {
|
|
|
3353
3809
|
};
|
|
3354
3810
|
}
|
|
3355
3811
|
function recordingPageFingerprint(items) {
|
|
3356
|
-
const hash =
|
|
3812
|
+
const hash = createHash2("sha256");
|
|
3357
3813
|
const ids = items.map((item) => String(item.id ?? "")).sort();
|
|
3358
3814
|
for (const id of ids) {
|
|
3359
3815
|
hash.update(String(id.length));
|
|
@@ -3364,12 +3820,16 @@ function recordingPageFingerprint(items) {
|
|
|
3364
3820
|
return `${items.length}:${hash.digest("hex")}`;
|
|
3365
3821
|
}
|
|
3366
3822
|
var cached = null;
|
|
3367
|
-
|
|
3368
|
-
|
|
3823
|
+
var localArtifactStorageOverride;
|
|
3824
|
+
function localArtifactStorageFor() {
|
|
3825
|
+
return localArtifactStorageOverride ?? resolveAudioArtifactStorage();
|
|
3826
|
+
}
|
|
3827
|
+
function getStore(env = process.env, options = {}) {
|
|
3828
|
+
if (env === process.env && cached && options.credentials === undefined)
|
|
3369
3829
|
return cached;
|
|
3370
|
-
const
|
|
3371
|
-
const store =
|
|
3372
|
-
if (env === process.env)
|
|
3830
|
+
const client = resolveRecordingsCloudClient(env, options);
|
|
3831
|
+
const store = client ? apiStore(client) : localStore;
|
|
3832
|
+
if (env === process.env && options.credentials === undefined)
|
|
3373
3833
|
cached = store;
|
|
3374
3834
|
return store;
|
|
3375
3835
|
}
|
|
@@ -3406,12 +3866,15 @@ class EnhancementError extends Error {
|
|
|
3406
3866
|
}
|
|
3407
3867
|
export {
|
|
3408
3868
|
toV1BaseUrl3 as toV1BaseUrl,
|
|
3409
|
-
|
|
3410
|
-
|
|
3869
|
+
resolveRecordingsTransport,
|
|
3870
|
+
resolveRecordingsCloudClient,
|
|
3871
|
+
isRecordingsLocalOptIn,
|
|
3411
3872
|
getStore,
|
|
3873
|
+
getRecordingsTransportStatus,
|
|
3412
3874
|
createStorageClient,
|
|
3413
3875
|
createHttpTransport,
|
|
3414
3876
|
__resetStore,
|
|
3415
|
-
|
|
3877
|
+
RECORDINGS_LOCAL_OPT_IN_ENV_KEYS,
|
|
3878
|
+
HasnaHttpError2 as HasnaHttpError,
|
|
3416
3879
|
APP
|
|
3417
3880
|
};
|