@hasna/recordings 0.3.13 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2879 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1713 -459
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +590 -287
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1358 -874
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- package/src/native/Recordings/App/SidebarView.swift +0 -212
package/dist/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.5.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: {
|
|
@@ -1048,6 +1039,19 @@ var package_default = {
|
|
|
1048
1039
|
"./sdk": {
|
|
1049
1040
|
import: "./dist/sdk/index.js",
|
|
1050
1041
|
types: "./dist/sdk/index.d.ts"
|
|
1042
|
+
},
|
|
1043
|
+
"./contracts/hosted-v1": {
|
|
1044
|
+
import: "./dist/contracts/hosted-v1.js",
|
|
1045
|
+
types: "./dist/contracts/hosted-v1.d.ts"
|
|
1046
|
+
},
|
|
1047
|
+
"./contracts/stream-v1": {
|
|
1048
|
+
import: "./dist/contracts/stream-v1.js",
|
|
1049
|
+
types: "./dist/contracts/stream-v1.d.ts"
|
|
1050
|
+
},
|
|
1051
|
+
"./contracts/fixtures/v1": "./contracts/v1/fixtures.json",
|
|
1052
|
+
"./hosted": {
|
|
1053
|
+
import: "./dist/hosted/index.js",
|
|
1054
|
+
types: "./dist/hosted/index.d.ts"
|
|
1051
1055
|
}
|
|
1052
1056
|
},
|
|
1053
1057
|
engines: {
|
|
@@ -1056,20 +1060,20 @@ var package_default = {
|
|
|
1056
1060
|
scripts: {
|
|
1057
1061
|
clean: "rm -rf dist",
|
|
1058
1062
|
build: "bun run clean && bun run build:cli && bun run build:mcp && bun run build:serve && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
1059
|
-
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
1060
|
-
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
1061
|
-
"build:serve": "bun build src/server/index.ts --target=bun --outfile=dist/server/index.js --external=@modelcontextprotocol/sdk --external=@hasna/contracts --external=openai --external=pg",
|
|
1062
|
-
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai",
|
|
1063
|
+
"build: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",
|
|
1064
|
+
"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",
|
|
1065
|
+
"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",
|
|
1066
|
+
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai --external=@aws-sdk/client-s3 && bun run build:contracts",
|
|
1063
1067
|
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
1064
1068
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
1065
1069
|
migrate: "bun run scripts/migrate.ts",
|
|
1066
1070
|
typecheck: "tsc --noEmit",
|
|
1067
1071
|
"typecheck:tcc-contract": "tsc --noEmit -p tsconfig.tcc-contract.json",
|
|
1068
|
-
test: "bun
|
|
1072
|
+
test: "bun scripts/release-suite-gate.ts --all",
|
|
1069
1073
|
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
1070
1074
|
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
1071
1075
|
"verify:ci-native": "bun scripts/ci-native-build.ts",
|
|
1072
|
-
"test:gated":
|
|
1076
|
+
"test:gated": "bun scripts/release-suite-gate.ts",
|
|
1073
1077
|
"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
1078
|
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
1075
1079
|
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
@@ -1087,11 +1091,16 @@ var package_default = {
|
|
|
1087
1091
|
"prepack:platform-gate": `bash -c 'if [ "$(uname -s)" = Darwin ]; then bun run build:native-fs-guard; else echo "WARN: native fs-guard build skipped on $(uname -s) \u2014 dry-run/CI pack; publish recordings from macOS"; fi'`,
|
|
1088
1092
|
prepublishOnly: "bun run typecheck && bun run release-suite-gate",
|
|
1089
1093
|
"release-suite-gate": "bun run scripts/release-suite-gate.ts",
|
|
1090
|
-
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json"
|
|
1094
|
+
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json",
|
|
1095
|
+
"build:contracts": "bun build src/contracts/hosted-v1.ts src/contracts/stream-v1.ts src/hosted/index.ts --target=bun --root src --outdir=dist --splitting --external=@hasna/contracts"
|
|
1091
1096
|
},
|
|
1092
1097
|
files: [
|
|
1093
1098
|
"dist/",
|
|
1099
|
+
"!dist/__tests__/",
|
|
1094
1100
|
"scripts/",
|
|
1101
|
+
"!scripts/test-recorder-fixtures.ts",
|
|
1102
|
+
"!scripts/**/__pycache__/",
|
|
1103
|
+
"!scripts/**/*.pyc",
|
|
1095
1104
|
"Dockerfile.package",
|
|
1096
1105
|
"bun.lock",
|
|
1097
1106
|
"src/native/Recordings/App/",
|
|
@@ -1103,10 +1112,14 @@ var package_default = {
|
|
|
1103
1112
|
"src/native/Recordings/build.sh",
|
|
1104
1113
|
"packaging/macos/",
|
|
1105
1114
|
"README.md",
|
|
1106
|
-
"LICENSE"
|
|
1115
|
+
"LICENSE",
|
|
1116
|
+
"contracts/v1/fixtures.json",
|
|
1117
|
+
"docs/wire-contracts.md",
|
|
1118
|
+
"docs/hosted-sdk.md"
|
|
1107
1119
|
],
|
|
1108
1120
|
dependencies: {
|
|
1109
|
-
"@
|
|
1121
|
+
"@aws-sdk/client-s3": "^3.1007.0",
|
|
1122
|
+
"@hasna/contracts": "1.0.2",
|
|
1110
1123
|
"@hasna/events": "0.1.11",
|
|
1111
1124
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
1112
1125
|
chalk: "^5.4.1",
|
|
@@ -1116,7 +1129,7 @@ var package_default = {
|
|
|
1116
1129
|
zod: "^3.24.2"
|
|
1117
1130
|
},
|
|
1118
1131
|
devDependencies: {
|
|
1119
|
-
"@types/bun": "
|
|
1132
|
+
"@types/bun": "1.3.14",
|
|
1120
1133
|
"@types/pg": "^8.11.11",
|
|
1121
1134
|
"node-api-headers": "1.9.0",
|
|
1122
1135
|
typescript: "^5.8.2"
|
|
@@ -1140,8 +1153,12 @@ function saveFeedback(input) {
|
|
|
1140
1153
|
|
|
1141
1154
|
// ../contracts/dist/client/transport.js
|
|
1142
1155
|
import { isIP } from "net";
|
|
1143
|
-
import {
|
|
1144
|
-
import {
|
|
1156
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1157
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync2 } from "fs";
|
|
1158
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
1159
|
+
import { createRequire } from "module";
|
|
1160
|
+
import { hostname as osHostname } from "os";
|
|
1161
|
+
import { isAbsolute as isAbsolute2, join as join3 } from "path";
|
|
1145
1162
|
function envToken(name) {
|
|
1146
1163
|
return name.toUpperCase().replace(/-/g, "_");
|
|
1147
1164
|
}
|
|
@@ -1156,6 +1173,9 @@ function credentialOverrideEnvKey(name) {
|
|
|
1156
1173
|
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
1157
1174
|
}
|
|
1158
1175
|
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
1176
|
+
function credentialPointerEnvKey(name) {
|
|
1177
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
1178
|
+
}
|
|
1159
1179
|
|
|
1160
1180
|
class CredentialResolutionError extends Error {
|
|
1161
1181
|
appName;
|
|
@@ -1167,34 +1187,70 @@ class CredentialResolutionError extends Error {
|
|
|
1167
1187
|
this.attempted = attempted;
|
|
1168
1188
|
}
|
|
1169
1189
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1190
|
+
|
|
1191
|
+
class CredentialFileUnsafeError extends Error {
|
|
1192
|
+
path;
|
|
1193
|
+
constructor(path, reason) {
|
|
1194
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
1195
|
+
this.name = "CredentialFileUnsafeError";
|
|
1196
|
+
this.path = path;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
1200
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
1201
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
1202
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
1203
|
+
var CONFIG_SUBDIR = "config";
|
|
1204
|
+
var CREDENTIALS_FILE = "credentials";
|
|
1205
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
1206
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
1207
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
1208
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
1174
1209
|
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
1175
1210
|
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1176
1211
|
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
1177
1212
|
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
1213
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
1178
1214
|
function homeDir(env) {
|
|
1179
1215
|
const home = env.HOME?.trim();
|
|
1180
1216
|
return home ? home : null;
|
|
1181
1217
|
}
|
|
1182
|
-
function
|
|
1183
|
-
|
|
1218
|
+
function absoluteOverride(env, key) {
|
|
1219
|
+
const value = env[key]?.trim();
|
|
1220
|
+
return value && isAbsolute2(value) ? value : null;
|
|
1184
1221
|
}
|
|
1185
|
-
function
|
|
1222
|
+
function hasnaHomeDir(env) {
|
|
1223
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
1224
|
+
if (override)
|
|
1225
|
+
return override;
|
|
1186
1226
|
const home = homeDir(env);
|
|
1187
|
-
|
|
1227
|
+
return home ? join3(home, HASNA_HOME_DIR) : null;
|
|
1228
|
+
}
|
|
1229
|
+
function appConfigDir(name, env) {
|
|
1230
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
1231
|
+
if (configRoot)
|
|
1232
|
+
return join3(configRoot, name);
|
|
1233
|
+
const root = hasnaHomeDir(env);
|
|
1234
|
+
return root ? join3(root, name, CONFIG_SUBDIR) : null;
|
|
1235
|
+
}
|
|
1236
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
1237
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
1188
1238
|
return [];
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1239
|
+
const directory = appConfigDir(name, env);
|
|
1240
|
+
if (!directory)
|
|
1241
|
+
return [];
|
|
1242
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
1243
|
+
return [{ path: join3(directory, file), tier: "disk" }];
|
|
1244
|
+
}
|
|
1245
|
+
function credentialDiskSources(name, env) {
|
|
1246
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
1247
|
+
}
|
|
1248
|
+
function profileDiskSources(name, env, profile) {
|
|
1249
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
1195
1250
|
}
|
|
1196
1251
|
function parseEnvFile(text) {
|
|
1197
1252
|
const values = new Map;
|
|
1253
|
+
const unusable = new Set;
|
|
1198
1254
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
1199
1255
|
const line = rawLine.trim();
|
|
1200
1256
|
if (line.length === 0 || line.startsWith("#"))
|
|
@@ -1209,38 +1265,81 @@ function parseEnvFile(text) {
|
|
|
1209
1265
|
let value = withoutExport.slice(equals + 1).trim();
|
|
1210
1266
|
const quote = value[0];
|
|
1211
1267
|
if (quote === '"' || quote === "'") {
|
|
1212
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
1268
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
1269
|
+
unusable.add(key);
|
|
1213
1270
|
continue;
|
|
1271
|
+
}
|
|
1214
1272
|
value = value.slice(1, -1);
|
|
1215
1273
|
}
|
|
1216
|
-
if (value.length === 0)
|
|
1274
|
+
if (value.trim().length === 0) {
|
|
1275
|
+
unusable.add(key);
|
|
1217
1276
|
continue;
|
|
1277
|
+
}
|
|
1278
|
+
if (values.has(key) && values.get(key) !== value)
|
|
1279
|
+
unusable.add(key);
|
|
1218
1280
|
values.set(key, value);
|
|
1219
1281
|
}
|
|
1220
|
-
return values;
|
|
1282
|
+
return { values, unusable };
|
|
1283
|
+
}
|
|
1284
|
+
function configFileModeAllowed(mode) {
|
|
1285
|
+
const permissions = mode & 4095;
|
|
1286
|
+
return permissions === 256 || permissions === 384;
|
|
1287
|
+
}
|
|
1288
|
+
function configFileReadsCoherent(before, after) {
|
|
1289
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
1221
1290
|
}
|
|
1222
1291
|
function readAppConfigFile(path) {
|
|
1223
|
-
|
|
1292
|
+
const unsafe = (reason) => {
|
|
1293
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
1294
|
+
};
|
|
1295
|
+
let fd = -1;
|
|
1224
1296
|
try {
|
|
1225
|
-
|
|
1226
|
-
|
|
1297
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
const code = error.code;
|
|
1300
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
1227
1301
|
return null;
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1302
|
+
if (code === "ELOOP")
|
|
1303
|
+
unsafe("the path is a symlink");
|
|
1304
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
const before = fstatSync(fd);
|
|
1308
|
+
if (!before.isFile())
|
|
1309
|
+
unsafe("the path is not a regular file");
|
|
1310
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
1311
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
1312
|
+
}
|
|
1313
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
1314
|
+
if (uid !== undefined && before.uid !== uid)
|
|
1315
|
+
unsafe("the file is not owned by the current user");
|
|
1316
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
1317
|
+
unsafe("the file exceeds the size limit");
|
|
1318
|
+
const bytes = readFileSync2(fd);
|
|
1319
|
+
const after = fstatSync(fd);
|
|
1320
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
1321
|
+
unsafe("the file changed while being read");
|
|
1322
|
+
}
|
|
1323
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
1324
|
+
} finally {
|
|
1325
|
+
if (fd !== -1)
|
|
1326
|
+
closeSync(fd);
|
|
1231
1327
|
}
|
|
1232
|
-
return parseEnvFile(text);
|
|
1233
1328
|
}
|
|
1234
1329
|
function readCredentialFile(path, apiKeyKeys) {
|
|
1235
|
-
const
|
|
1236
|
-
if (!
|
|
1330
|
+
const parsed = readAppConfigFile(path);
|
|
1331
|
+
if (!parsed)
|
|
1237
1332
|
return null;
|
|
1238
1333
|
for (const key of apiKeyKeys) {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1334
|
+
if (parsed.unusable.has(key)) {
|
|
1335
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
1336
|
+
}
|
|
1242
1337
|
}
|
|
1243
|
-
|
|
1338
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
1339
|
+
if (new Set(values).size > 1) {
|
|
1340
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
1341
|
+
}
|
|
1342
|
+
return values[0] ?? null;
|
|
1244
1343
|
}
|
|
1245
1344
|
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
1246
1345
|
function appConfigDiskValue(name, env, keys) {
|
|
@@ -1248,11 +1347,19 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
1248
1347
|
if (wanted.length === 0)
|
|
1249
1348
|
return null;
|
|
1250
1349
|
for (const path of credentialDiskSources(name, env)) {
|
|
1251
|
-
const
|
|
1252
|
-
if (!
|
|
1350
|
+
const parsed = readAppConfigFile(path);
|
|
1351
|
+
if (!parsed)
|
|
1253
1352
|
continue;
|
|
1353
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
1354
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
1355
|
+
}
|
|
1356
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
1357
|
+
if (new Set(values).size > 1)
|
|
1358
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
1254
1359
|
for (const key of wanted) {
|
|
1255
|
-
|
|
1360
|
+
if (parsed.unusable.has(key))
|
|
1361
|
+
return { key, value: "", path, unusable: true };
|
|
1362
|
+
const value = parsed.values.get(key)?.trim();
|
|
1256
1363
|
if (value)
|
|
1257
1364
|
return { key, value, path };
|
|
1258
1365
|
}
|
|
@@ -1260,20 +1367,21 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
1260
1367
|
return null;
|
|
1261
1368
|
}
|
|
1262
1369
|
function assertUsableCredential(appName, source, value) {
|
|
1370
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
1371
|
+
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]);
|
|
1372
|
+
}
|
|
1263
1373
|
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
1264
1374
|
return;
|
|
1265
1375
|
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
1376
|
}
|
|
1267
1377
|
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
1268
1378
|
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
1269
|
-
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
1270
1379
|
function sealCredential(fields) {
|
|
1271
1380
|
const { apiKey } = fields;
|
|
1272
1381
|
const visible = {
|
|
1273
1382
|
tier: fields.tier,
|
|
1274
1383
|
source: fields.source,
|
|
1275
1384
|
deliberate: fields.deliberate,
|
|
1276
|
-
deprecated: fields.deprecated,
|
|
1277
1385
|
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
1278
1386
|
warning: fields.warning
|
|
1279
1387
|
};
|
|
@@ -1284,6 +1392,14 @@ function sealCredential(fields) {
|
|
|
1284
1392
|
writable: false,
|
|
1285
1393
|
configurable: false
|
|
1286
1394
|
});
|
|
1395
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
1396
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
1397
|
+
value: fields.pointerVaultKey,
|
|
1398
|
+
enumerable: false,
|
|
1399
|
+
writable: false,
|
|
1400
|
+
configurable: false
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1287
1403
|
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
1288
1404
|
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
1289
1405
|
enumerable: false,
|
|
@@ -1298,88 +1414,142 @@ function sealCredential(fields) {
|
|
|
1298
1414
|
});
|
|
1299
1415
|
return Object.freeze(sealed);
|
|
1300
1416
|
}
|
|
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
1417
|
function firstEnvValue(env, keys) {
|
|
1342
1418
|
for (const key of keys) {
|
|
1419
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
1420
|
+
continue;
|
|
1343
1421
|
const value = env[key]?.trim();
|
|
1344
1422
|
if (value)
|
|
1345
1423
|
return { key, value };
|
|
1346
1424
|
}
|
|
1347
1425
|
return null;
|
|
1348
1426
|
}
|
|
1349
|
-
var
|
|
1350
|
-
function
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1427
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
1428
|
+
function isAmbientEnvironment(env) {
|
|
1429
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
1430
|
+
}
|
|
1431
|
+
function defaultKeychainRunner(argv) {
|
|
1432
|
+
const result = spawnSync2(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
1433
|
+
encoding: "utf8",
|
|
1434
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1435
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
1436
|
+
});
|
|
1437
|
+
return {
|
|
1438
|
+
status: result.status,
|
|
1439
|
+
stdout: result.stdout ?? "",
|
|
1440
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
function keychainTierEnabled(env, options) {
|
|
1444
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
1445
|
+
return false;
|
|
1446
|
+
if (options.enabled !== undefined)
|
|
1447
|
+
return options.enabled;
|
|
1448
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
1449
|
+
}
|
|
1450
|
+
function keychainAccount(env, options) {
|
|
1451
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
1452
|
+
if (station)
|
|
1453
|
+
return station;
|
|
1454
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
1455
|
+
if (host)
|
|
1456
|
+
return host;
|
|
1457
|
+
const user = env.USER?.trim();
|
|
1458
|
+
return user || null;
|
|
1459
|
+
}
|
|
1460
|
+
function keychainFailureHint(text) {
|
|
1461
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
1462
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
1463
|
+
return clean ? `: ${clean}` : "";
|
|
1464
|
+
}
|
|
1465
|
+
function readKeychainItem(name, env, kind, options) {
|
|
1466
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
1467
|
+
return null;
|
|
1468
|
+
const account = keychainAccount(env, options);
|
|
1469
|
+
if (!account)
|
|
1470
|
+
return null;
|
|
1471
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
1472
|
+
const source = `keychain:${service}@${account}`;
|
|
1473
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
1474
|
+
let result;
|
|
1475
|
+
try {
|
|
1476
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
1479
|
+
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]);
|
|
1480
|
+
}
|
|
1481
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
1482
|
+
return null;
|
|
1483
|
+
if (result.status !== 0) {
|
|
1484
|
+
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]);
|
|
1485
|
+
}
|
|
1486
|
+
const value = result.stdout.trim();
|
|
1487
|
+
if (!value) {
|
|
1488
|
+
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]);
|
|
1489
|
+
}
|
|
1490
|
+
return { value, source };
|
|
1491
|
+
}
|
|
1492
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
1493
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
1358
1494
|
}
|
|
1359
|
-
function
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1495
|
+
function snapshotClientEnvironment(name, env) {
|
|
1496
|
+
const keys = clientTransportEnvKeys(name);
|
|
1497
|
+
const ambient = isAmbientEnvironment(env);
|
|
1498
|
+
const snapshot = Object.create(null);
|
|
1499
|
+
for (const key of [
|
|
1500
|
+
...keys.apiUrlKeys,
|
|
1501
|
+
...keys.apiKeyKeys,
|
|
1502
|
+
credentialOverrideEnvKey(name),
|
|
1503
|
+
credentialPointerEnvKey(name),
|
|
1504
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
1505
|
+
"HOME",
|
|
1506
|
+
HASNA_HOME_ENV_KEY,
|
|
1507
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
1508
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
1509
|
+
"USER"
|
|
1510
|
+
]) {
|
|
1511
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
1512
|
+
if (!descriptor)
|
|
1513
|
+
continue;
|
|
1514
|
+
if (!("value" in descriptor)) {
|
|
1515
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
1516
|
+
}
|
|
1517
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
1518
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
1519
|
+
}
|
|
1520
|
+
snapshot[key] = descriptor.value;
|
|
1521
|
+
}
|
|
1522
|
+
if (ambient) {
|
|
1523
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
1524
|
+
value: true,
|
|
1525
|
+
enumerable: false,
|
|
1526
|
+
writable: false,
|
|
1527
|
+
configurable: false
|
|
1528
|
+
});
|
|
1363
1529
|
}
|
|
1530
|
+
return Object.freeze(snapshot);
|
|
1364
1531
|
}
|
|
1365
1532
|
function resolveCredential(name, env, options = {}) {
|
|
1533
|
+
env = snapshotClientEnvironment(name, env);
|
|
1366
1534
|
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
1367
1535
|
const diskPaths = credentialDiskSources(name, env);
|
|
1368
|
-
|
|
1369
|
-
|
|
1536
|
+
if (options.apiKey !== undefined) {
|
|
1537
|
+
const explicitKey = options.apiKey.trim();
|
|
1538
|
+
if (!explicitKey) {
|
|
1539
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
1540
|
+
}
|
|
1370
1541
|
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
1371
1542
|
return sealCredential({
|
|
1372
1543
|
apiKey: explicitKey,
|
|
1373
1544
|
tier: "argument",
|
|
1374
1545
|
source: "explicit apiKey argument",
|
|
1375
1546
|
deliberate: true,
|
|
1376
|
-
deprecated: false,
|
|
1377
1547
|
diskCandidates: diskPaths,
|
|
1378
1548
|
warning: null
|
|
1379
1549
|
});
|
|
1380
1550
|
}
|
|
1381
1551
|
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
1382
|
-
const overrideRaw = env[overrideKeyName];
|
|
1552
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
1383
1553
|
if (overrideRaw !== undefined) {
|
|
1384
1554
|
const override = overrideRaw.trim();
|
|
1385
1555
|
if (!override) {
|
|
@@ -1391,12 +1561,38 @@ function resolveCredential(name, env, options = {}) {
|
|
|
1391
1561
|
tier: "override",
|
|
1392
1562
|
source: overrideKeyName,
|
|
1393
1563
|
deliberate: true,
|
|
1394
|
-
deprecated: false,
|
|
1395
1564
|
diskCandidates: diskPaths,
|
|
1396
1565
|
warning: null
|
|
1397
1566
|
});
|
|
1398
1567
|
}
|
|
1399
|
-
const
|
|
1568
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
1569
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
1570
|
+
if (pointerRaw !== undefined) {
|
|
1571
|
+
const pointer = pointerRaw.trim();
|
|
1572
|
+
if (!pointer) {
|
|
1573
|
+
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]);
|
|
1574
|
+
}
|
|
1575
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
1576
|
+
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]);
|
|
1577
|
+
}
|
|
1578
|
+
return sealCredential({
|
|
1579
|
+
apiKey: "",
|
|
1580
|
+
pointerVaultKey: pointer,
|
|
1581
|
+
tier: "pointer",
|
|
1582
|
+
source: pointerKeyName,
|
|
1583
|
+
deliberate: true,
|
|
1584
|
+
diskCandidates: diskPaths,
|
|
1585
|
+
warning: null
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
1589
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
1590
|
+
}
|
|
1591
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
1592
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
1593
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
1594
|
+
}
|
|
1595
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
1400
1596
|
if (profile) {
|
|
1401
1597
|
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
1402
1598
|
if (!SAFE_PROFILE.test(profile)) {
|
|
@@ -1412,7 +1608,6 @@ function resolveCredential(name, env, options = {}) {
|
|
|
1412
1608
|
tier: "profile",
|
|
1413
1609
|
source: path,
|
|
1414
1610
|
deliberate: true,
|
|
1415
|
-
deprecated: false,
|
|
1416
1611
|
diskCandidates: paths,
|
|
1417
1612
|
warning: null
|
|
1418
1613
|
});
|
|
@@ -1420,51 +1615,67 @@ function resolveCredential(name, env, options = {}) {
|
|
|
1420
1615
|
}
|
|
1421
1616
|
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
1617
|
}
|
|
1423
|
-
const
|
|
1618
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
1619
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
1620
|
+
if (blankEnv) {
|
|
1621
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
1622
|
+
}
|
|
1623
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
1624
|
+
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));
|
|
1625
|
+
}
|
|
1626
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
1627
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
1628
|
+
if (keychainHit) {
|
|
1629
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
1630
|
+
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;
|
|
1631
|
+
return sealCredential({
|
|
1632
|
+
apiKey: keychainHit.value,
|
|
1633
|
+
tier: "keychain",
|
|
1634
|
+
source: keychainHit.source,
|
|
1635
|
+
deliberate: false,
|
|
1636
|
+
diskCandidates: diskPaths,
|
|
1637
|
+
warning
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
1641
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
1424
1642
|
if (diskHits.length > 0) {
|
|
1425
1643
|
const winner = diskHits[0];
|
|
1426
|
-
assertUsableCredential(name, winner.path, winner.value);
|
|
1644
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
1427
1645
|
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
|
-
})()
|
|
1646
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
1647
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
1433
1648
|
];
|
|
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;
|
|
1649
|
+
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
1650
|
return sealCredential({
|
|
1436
1651
|
apiKey: winner.value,
|
|
1437
|
-
tier:
|
|
1438
|
-
source: winner.path,
|
|
1652
|
+
tier: winner.src.tier,
|
|
1653
|
+
source: winner.src.path,
|
|
1439
1654
|
deliberate: false,
|
|
1440
|
-
deprecated: false,
|
|
1441
1655
|
diskCandidates: diskPaths,
|
|
1442
1656
|
warning
|
|
1443
1657
|
});
|
|
1444
1658
|
}
|
|
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
|
-
}
|
|
1659
|
+
if (envHit) {
|
|
1660
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
1456
1661
|
return sealCredential({
|
|
1457
|
-
apiKey:
|
|
1458
|
-
tier: "
|
|
1459
|
-
source:
|
|
1662
|
+
apiKey: envHit.value,
|
|
1663
|
+
tier: "env",
|
|
1664
|
+
source: envHit.key,
|
|
1460
1665
|
deliberate: false,
|
|
1461
|
-
deprecated: true,
|
|
1462
1666
|
diskCandidates: diskPaths,
|
|
1463
|
-
warning:
|
|
1667
|
+
warning: null
|
|
1464
1668
|
});
|
|
1465
1669
|
}
|
|
1466
1670
|
return null;
|
|
1467
1671
|
}
|
|
1672
|
+
var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
1673
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
1674
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
|
|
1675
|
+
var DEFAULT_AUTHORITY_SOURCE = "default";
|
|
1676
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
1677
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
1678
|
+
}
|
|
1468
1679
|
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
1469
1680
|
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1470
1681
|
function isValidDnsDomain(value) {
|
|
@@ -1473,21 +1684,11 @@ function isValidDnsDomain(value) {
|
|
|
1473
1684
|
}
|
|
1474
1685
|
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
1475
1686
|
}
|
|
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;
|
|
1687
|
+
function validateAppSlug(name) {
|
|
1688
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
1689
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
1489
1690
|
}
|
|
1490
|
-
return
|
|
1691
|
+
return name;
|
|
1491
1692
|
}
|
|
1492
1693
|
function rawAuthority(value) {
|
|
1493
1694
|
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
@@ -1590,140 +1791,102 @@ function toV1BaseUrl(apiUrl) {
|
|
|
1590
1791
|
url.pathname = `${path}/v1`;
|
|
1591
1792
|
return url.toString().replace(/\/+$/, "");
|
|
1592
1793
|
}
|
|
1593
|
-
|
|
1794
|
+
class ClientTransportConfigurationError extends Error {
|
|
1795
|
+
appName;
|
|
1796
|
+
sources;
|
|
1797
|
+
constructor(appName, message, sources = []) {
|
|
1798
|
+
super(message);
|
|
1799
|
+
this.name = "ClientTransportConfigurationError";
|
|
1800
|
+
this.appName = appName;
|
|
1801
|
+
this.sources = Object.freeze([...sources]);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
1805
|
+
env = snapshotClientEnvironment(name, env);
|
|
1594
1806
|
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
|
-
};
|
|
1807
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
1808
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
1809
|
+
if (blankUrl) {
|
|
1810
|
+
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]);
|
|
1811
|
+
}
|
|
1812
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
1813
|
+
if (controlledUrl) {
|
|
1814
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
1815
|
+
}
|
|
1816
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
1817
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
1818
|
+
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));
|
|
1819
|
+
}
|
|
1820
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
1821
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
1822
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
1823
|
+
if (diskConfigUrlHit?.unusable) {
|
|
1824
|
+
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]);
|
|
1825
|
+
}
|
|
1826
|
+
const urlCandidates = [
|
|
1827
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
1828
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
1829
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
1830
|
+
];
|
|
1831
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
1832
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
1833
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
1834
|
+
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
1835
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1836
|
+
const warnings = [];
|
|
1837
|
+
if (configuredUrl && !envUrlHit) {
|
|
1838
|
+
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
1839
|
}
|
|
1634
1840
|
const credential = resolveCredential(name, env, options.credentials);
|
|
1635
1841
|
if (!credential) {
|
|
1636
1842
|
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
|
-
};
|
|
1843
|
+
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`;
|
|
1844
|
+
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.`);
|
|
1845
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
1649
1846
|
}
|
|
1650
1847
|
if (credential.warning)
|
|
1651
1848
|
warnings.push(credential.warning);
|
|
1849
|
+
let urlHit;
|
|
1850
|
+
if (configuredUrl) {
|
|
1851
|
+
urlHit = configuredUrl;
|
|
1852
|
+
} else {
|
|
1853
|
+
try {
|
|
1854
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1857
|
+
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]]);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1652
1860
|
const apiUrlSource = urlHit.key;
|
|
1653
1861
|
let baseUrl;
|
|
1654
1862
|
try {
|
|
1655
1863
|
baseUrl = toV1BaseUrl(urlHit.value);
|
|
1656
1864
|
} catch (error) {
|
|
1657
1865
|
const message = error instanceof Error ? error.message : String(error);
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1866
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
1867
|
+
}
|
|
1868
|
+
return {
|
|
1869
|
+
resolution: {
|
|
1870
|
+
transport: "http",
|
|
1661
1871
|
transportSource: urlHit.key,
|
|
1662
|
-
baseUrl
|
|
1663
|
-
apiUrlSource
|
|
1872
|
+
baseUrl,
|
|
1873
|
+
apiUrlSource,
|
|
1664
1874
|
apiKeyPresent: true,
|
|
1665
1875
|
apiKeySource: credential.source,
|
|
1666
1876
|
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
|
|
1877
|
+
misconfigured: false,
|
|
1878
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1879
|
+
},
|
|
1880
|
+
credential
|
|
1681
1881
|
};
|
|
1682
1882
|
}
|
|
1883
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
1884
|
+
return resolveClientTransportSnapshot(name, env, options).resolution;
|
|
1885
|
+
}
|
|
1683
1886
|
function credentialDiskSourcesForMessage(name, env) {
|
|
1684
1887
|
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.`;
|
|
1888
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
1725
1889
|
}
|
|
1726
|
-
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
1727
1890
|
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
1728
1891
|
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
1729
1892
|
"host",
|
|
@@ -1732,215 +1895,32 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
|
1732
1895
|
"x-forwarded-host",
|
|
1733
1896
|
"x-original-host"
|
|
1734
1897
|
]);
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1898
|
+
|
|
1899
|
+
// ../contracts/dist/client/storage.js
|
|
1900
|
+
import { isIP as isIP2 } from "net";
|
|
1901
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1902
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync3 } from "fs";
|
|
1903
|
+
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|
|
1904
|
+
import { createRequire as createRequire2 } from "module";
|
|
1905
|
+
import { hostname as osHostname2 } from "os";
|
|
1906
|
+
import { isAbsolute as isAbsolute3, join as join4 } from "path";
|
|
1907
|
+
function envToken2(name) {
|
|
1908
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
1742
1909
|
}
|
|
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
|
-
};
|
|
1910
|
+
function clientTransportEnvKeys2(name) {
|
|
1911
|
+
const envSegment = envToken2(name);
|
|
1912
|
+
return {
|
|
1913
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
1914
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
1915
|
+
};
|
|
1939
1916
|
}
|
|
1940
1917
|
function credentialOverrideEnvKey2(name) {
|
|
1941
1918
|
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
1942
1919
|
}
|
|
1943
1920
|
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
1921
|
+
function credentialPointerEnvKey2(name) {
|
|
1922
|
+
return `HASNA_${envToken2(name)}_API_KEY_REF`;
|
|
1923
|
+
}
|
|
1944
1924
|
|
|
1945
1925
|
class CredentialResolutionError2 extends Error {
|
|
1946
1926
|
appName;
|
|
@@ -1952,34 +1932,70 @@ class CredentialResolutionError2 extends Error {
|
|
|
1952
1932
|
this.attempted = attempted;
|
|
1953
1933
|
}
|
|
1954
1934
|
}
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1935
|
+
|
|
1936
|
+
class CredentialFileUnsafeError2 extends Error {
|
|
1937
|
+
path;
|
|
1938
|
+
constructor(path, reason) {
|
|
1939
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
1940
|
+
this.name = "CredentialFileUnsafeError";
|
|
1941
|
+
this.path = path;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
var HASNA_HOME_ENV_KEY2 = "HASNA_HOME";
|
|
1945
|
+
var HASNA_CONFIG_HOME_ENV_KEY2 = "HASNA_CONFIG_HOME";
|
|
1946
|
+
var KEYCHAIN_STATION_ENV_KEY2 = "HASNA_STATION";
|
|
1947
|
+
var HASNA_HOME_DIR2 = ".hasna";
|
|
1948
|
+
var CONFIG_SUBDIR2 = "config";
|
|
1949
|
+
var CREDENTIALS_FILE2 = "credentials";
|
|
1950
|
+
var KEYCHAIN_SECURITY_BIN2 = "/usr/bin/security";
|
|
1951
|
+
var KEYCHAIN_SERVICE_PREFIX2 = "hasna.credentials";
|
|
1952
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS2 = 44;
|
|
1953
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS2 = 1e4;
|
|
1959
1954
|
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
1960
1955
|
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1961
1956
|
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
1962
1957
|
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
1958
|
+
var VAULT_POINTER_SHAPE2 = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
1963
1959
|
function homeDir2(env) {
|
|
1964
1960
|
const home = env.HOME?.trim();
|
|
1965
1961
|
return home ? home : null;
|
|
1966
1962
|
}
|
|
1967
|
-
function
|
|
1968
|
-
|
|
1963
|
+
function absoluteOverride2(env, key) {
|
|
1964
|
+
const value = env[key]?.trim();
|
|
1965
|
+
return value && isAbsolute3(value) ? value : null;
|
|
1969
1966
|
}
|
|
1970
|
-
function
|
|
1967
|
+
function hasnaHomeDir2(env) {
|
|
1968
|
+
const override = absoluteOverride2(env, HASNA_HOME_ENV_KEY2);
|
|
1969
|
+
if (override)
|
|
1970
|
+
return override;
|
|
1971
1971
|
const home = homeDir2(env);
|
|
1972
|
-
|
|
1972
|
+
return home ? join4(home, HASNA_HOME_DIR2) : null;
|
|
1973
|
+
}
|
|
1974
|
+
function appConfigDir2(name, env) {
|
|
1975
|
+
const configRoot = absoluteOverride2(env, HASNA_CONFIG_HOME_ENV_KEY2);
|
|
1976
|
+
if (configRoot)
|
|
1977
|
+
return join4(configRoot, name);
|
|
1978
|
+
const root = hasnaHomeDir2(env);
|
|
1979
|
+
return root ? join4(root, name, CONFIG_SUBDIR2) : null;
|
|
1980
|
+
}
|
|
1981
|
+
function credentialDiskSourceList2(name, env, profile = null) {
|
|
1982
|
+
if (!SAFE_APP_SLUG2.test(name))
|
|
1973
1983
|
return [];
|
|
1974
|
-
const
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1984
|
+
const directory = appConfigDir2(name, env);
|
|
1985
|
+
if (!directory)
|
|
1986
|
+
return [];
|
|
1987
|
+
const file = profile ? `${CREDENTIALS_FILE2}-${profile}` : CREDENTIALS_FILE2;
|
|
1988
|
+
return [{ path: join4(directory, file), tier: "disk" }];
|
|
1989
|
+
}
|
|
1990
|
+
function credentialDiskSources2(name, env) {
|
|
1991
|
+
return credentialDiskSourceList2(name, env, null).map((s) => s.path);
|
|
1992
|
+
}
|
|
1993
|
+
function profileDiskSources2(name, env, profile) {
|
|
1994
|
+
return credentialDiskSourceList2(name, env, profile).map((s) => s.path);
|
|
1980
1995
|
}
|
|
1981
1996
|
function parseEnvFile2(text) {
|
|
1982
1997
|
const values = new Map;
|
|
1998
|
+
const unusable = new Set;
|
|
1983
1999
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
1984
2000
|
const line = rawLine.trim();
|
|
1985
2001
|
if (line.length === 0 || line.startsWith("#"))
|
|
@@ -1994,38 +2010,81 @@ function parseEnvFile2(text) {
|
|
|
1994
2010
|
let value = withoutExport.slice(equals + 1).trim();
|
|
1995
2011
|
const quote = value[0];
|
|
1996
2012
|
if (quote === '"' || quote === "'") {
|
|
1997
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
2013
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
2014
|
+
unusable.add(key);
|
|
1998
2015
|
continue;
|
|
2016
|
+
}
|
|
1999
2017
|
value = value.slice(1, -1);
|
|
2000
2018
|
}
|
|
2001
|
-
if (value.length === 0)
|
|
2019
|
+
if (value.trim().length === 0) {
|
|
2020
|
+
unusable.add(key);
|
|
2002
2021
|
continue;
|
|
2022
|
+
}
|
|
2023
|
+
if (values.has(key) && values.get(key) !== value)
|
|
2024
|
+
unusable.add(key);
|
|
2003
2025
|
values.set(key, value);
|
|
2004
2026
|
}
|
|
2005
|
-
return values;
|
|
2027
|
+
return { values, unusable };
|
|
2028
|
+
}
|
|
2029
|
+
function configFileModeAllowed2(mode) {
|
|
2030
|
+
const permissions = mode & 4095;
|
|
2031
|
+
return permissions === 256 || permissions === 384;
|
|
2032
|
+
}
|
|
2033
|
+
function configFileReadsCoherent2(before, after) {
|
|
2034
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
2006
2035
|
}
|
|
2007
2036
|
function readAppConfigFile2(path) {
|
|
2008
|
-
|
|
2037
|
+
const unsafe = (reason) => {
|
|
2038
|
+
throw new CredentialFileUnsafeError2(path, reason);
|
|
2039
|
+
};
|
|
2040
|
+
let fd = -1;
|
|
2009
2041
|
try {
|
|
2010
|
-
|
|
2011
|
-
|
|
2042
|
+
fd = openSync2(path, O_RDONLY2 | O_NOFOLLOW2 | O_NONBLOCK2);
|
|
2043
|
+
} catch (error) {
|
|
2044
|
+
const code = error.code;
|
|
2045
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
2012
2046
|
return null;
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2047
|
+
if (code === "ELOOP")
|
|
2048
|
+
unsafe("the path is a symlink");
|
|
2049
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
2050
|
+
}
|
|
2051
|
+
try {
|
|
2052
|
+
const before = fstatSync2(fd);
|
|
2053
|
+
if (!before.isFile())
|
|
2054
|
+
unsafe("the path is not a regular file");
|
|
2055
|
+
if (!configFileModeAllowed2(before.mode)) {
|
|
2056
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
2057
|
+
}
|
|
2058
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
2059
|
+
if (uid !== undefined && before.uid !== uid)
|
|
2060
|
+
unsafe("the file is not owned by the current user");
|
|
2061
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
2062
|
+
unsafe("the file exceeds the size limit");
|
|
2063
|
+
const bytes = readFileSync3(fd);
|
|
2064
|
+
const after = fstatSync2(fd);
|
|
2065
|
+
if (!configFileReadsCoherent2(before, after)) {
|
|
2066
|
+
unsafe("the file changed while being read");
|
|
2067
|
+
}
|
|
2068
|
+
return parseEnvFile2(bytes.toString("utf8"));
|
|
2069
|
+
} finally {
|
|
2070
|
+
if (fd !== -1)
|
|
2071
|
+
closeSync2(fd);
|
|
2016
2072
|
}
|
|
2017
|
-
return parseEnvFile2(text);
|
|
2018
2073
|
}
|
|
2019
2074
|
function readCredentialFile2(path, apiKeyKeys) {
|
|
2020
|
-
const
|
|
2021
|
-
if (!
|
|
2075
|
+
const parsed = readAppConfigFile2(path);
|
|
2076
|
+
if (!parsed)
|
|
2022
2077
|
return null;
|
|
2023
2078
|
for (const key of apiKeyKeys) {
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2079
|
+
if (parsed.unusable.has(key)) {
|
|
2080
|
+
throw new CredentialFileUnsafeError2(path, `${key} is declared but blank or malformed`);
|
|
2081
|
+
}
|
|
2027
2082
|
}
|
|
2028
|
-
|
|
2083
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2084
|
+
if (new Set(values).size > 1) {
|
|
2085
|
+
throw new CredentialFileUnsafeError2(path, "credential aliases disagree");
|
|
2086
|
+
}
|
|
2087
|
+
return values[0] ?? null;
|
|
2029
2088
|
}
|
|
2030
2089
|
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2031
2090
|
function appConfigDiskValue2(name, env, keys) {
|
|
@@ -2033,11 +2092,19 @@ function appConfigDiskValue2(name, env, keys) {
|
|
|
2033
2092
|
if (wanted.length === 0)
|
|
2034
2093
|
return null;
|
|
2035
2094
|
for (const path of credentialDiskSources2(name, env)) {
|
|
2036
|
-
const
|
|
2037
|
-
if (!
|
|
2095
|
+
const parsed = readAppConfigFile2(path);
|
|
2096
|
+
if (!parsed)
|
|
2038
2097
|
continue;
|
|
2098
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
2099
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
2100
|
+
}
|
|
2101
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2102
|
+
if (new Set(values).size > 1)
|
|
2103
|
+
throw new CredentialFileUnsafeError2(path, "configuration aliases disagree");
|
|
2039
2104
|
for (const key of wanted) {
|
|
2040
|
-
|
|
2105
|
+
if (parsed.unusable.has(key))
|
|
2106
|
+
return { key, value: "", path, unusable: true };
|
|
2107
|
+
const value = parsed.values.get(key)?.trim();
|
|
2041
2108
|
if (value)
|
|
2042
2109
|
return { key, value, path };
|
|
2043
2110
|
}
|
|
@@ -2045,20 +2112,22 @@ function appConfigDiskValue2(name, env, keys) {
|
|
|
2045
2112
|
return null;
|
|
2046
2113
|
}
|
|
2047
2114
|
function assertUsableCredential2(appName, source, value) {
|
|
2115
|
+
if (VAULT_POINTER_SHAPE2.test(value)) {
|
|
2116
|
+
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]);
|
|
2117
|
+
}
|
|
2048
2118
|
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
2049
2119
|
return;
|
|
2050
2120
|
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
2121
|
}
|
|
2052
2122
|
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
2053
2123
|
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
2054
|
-
var
|
|
2124
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
2055
2125
|
function sealCredential2(fields) {
|
|
2056
2126
|
const { apiKey } = fields;
|
|
2057
2127
|
const visible = {
|
|
2058
2128
|
tier: fields.tier,
|
|
2059
2129
|
source: fields.source,
|
|
2060
2130
|
deliberate: fields.deliberate,
|
|
2061
|
-
deprecated: fields.deprecated,
|
|
2062
2131
|
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
2063
2132
|
warning: fields.warning
|
|
2064
2133
|
};
|
|
@@ -2069,6 +2138,14 @@ function sealCredential2(fields) {
|
|
|
2069
2138
|
writable: false,
|
|
2070
2139
|
configurable: false
|
|
2071
2140
|
});
|
|
2141
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
2142
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
2143
|
+
value: fields.pointerVaultKey,
|
|
2144
|
+
enumerable: false,
|
|
2145
|
+
writable: false,
|
|
2146
|
+
configurable: false
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2072
2149
|
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
2073
2150
|
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
2074
2151
|
enumerable: false,
|
|
@@ -2083,10 +2160,10 @@ function sealCredential2(fields) {
|
|
|
2083
2160
|
});
|
|
2084
2161
|
return Object.freeze(sealed);
|
|
2085
2162
|
}
|
|
2086
|
-
function
|
|
2163
|
+
function isSealedCredential(credential) {
|
|
2087
2164
|
return credential[CREDENTIAL_SEAL2] === true;
|
|
2088
2165
|
}
|
|
2089
|
-
function
|
|
2166
|
+
function explicitCredential(appName, apiKey) {
|
|
2090
2167
|
const source = "explicit apiKey option";
|
|
2091
2168
|
assertUsableCredential2(appName, source, apiKey);
|
|
2092
2169
|
return sealCredential2({
|
|
@@ -2094,21 +2171,19 @@ function explicitCredential2(appName, apiKey) {
|
|
|
2094
2171
|
tier: "argument",
|
|
2095
2172
|
source,
|
|
2096
2173
|
deliberate: true,
|
|
2097
|
-
deprecated: false,
|
|
2098
2174
|
diskCandidates: [],
|
|
2099
2175
|
warning: null
|
|
2100
2176
|
});
|
|
2101
2177
|
}
|
|
2102
|
-
function
|
|
2178
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
2103
2179
|
const apiKey = credential.apiKey;
|
|
2104
|
-
assertUsableCredential2(appName,
|
|
2105
|
-
if (!
|
|
2180
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
2181
|
+
if (!isSealedCredential(credential)) {
|
|
2106
2182
|
return sealCredential2({
|
|
2107
2183
|
apiKey,
|
|
2108
2184
|
tier: "argument",
|
|
2109
|
-
source:
|
|
2185
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
2110
2186
|
deliberate: true,
|
|
2111
|
-
deprecated: false,
|
|
2112
2187
|
diskCandidates: [],
|
|
2113
2188
|
warning: null
|
|
2114
2189
|
});
|
|
@@ -2118,53 +2193,147 @@ function validateAndSealResolvedCredential2(appName, credential) {
|
|
|
2118
2193
|
tier: credential.tier,
|
|
2119
2194
|
source: credential.source,
|
|
2120
2195
|
deliberate: credential.deliberate,
|
|
2121
|
-
deprecated: credential.deprecated,
|
|
2122
2196
|
diskCandidates: credential.diskCandidates,
|
|
2123
|
-
warning: credential.warning
|
|
2197
|
+
warning: credential.warning,
|
|
2198
|
+
...credential.pointerVaultKey !== undefined ? { pointerVaultKey: credential.pointerVaultKey } : {}
|
|
2124
2199
|
});
|
|
2125
2200
|
}
|
|
2126
2201
|
function firstEnvValue2(env, keys) {
|
|
2127
2202
|
for (const key of keys) {
|
|
2203
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
2204
|
+
continue;
|
|
2128
2205
|
const value = env[key]?.trim();
|
|
2129
2206
|
if (value)
|
|
2130
2207
|
return { key, value };
|
|
2131
2208
|
}
|
|
2132
2209
|
return null;
|
|
2133
2210
|
}
|
|
2134
|
-
var
|
|
2135
|
-
function
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2211
|
+
var AMBIENT_ENVIRONMENT2 = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
2212
|
+
function isAmbientEnvironment2(env) {
|
|
2213
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT2] === true;
|
|
2214
|
+
}
|
|
2215
|
+
function defaultKeychainRunner2(argv) {
|
|
2216
|
+
const result = spawnSync3(KEYCHAIN_SECURITY_BIN2, [...argv], {
|
|
2217
|
+
encoding: "utf8",
|
|
2218
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2219
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS2
|
|
2220
|
+
});
|
|
2221
|
+
return {
|
|
2222
|
+
status: result.status,
|
|
2223
|
+
stdout: result.stdout ?? "",
|
|
2224
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
function keychainTierEnabled2(env, options) {
|
|
2228
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
2229
|
+
return false;
|
|
2230
|
+
if (options.enabled !== undefined)
|
|
2231
|
+
return options.enabled;
|
|
2232
|
+
return options.run !== undefined || isAmbientEnvironment2(env);
|
|
2233
|
+
}
|
|
2234
|
+
function keychainAccount2(env, options) {
|
|
2235
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY2]?.trim();
|
|
2236
|
+
if (station)
|
|
2237
|
+
return station;
|
|
2238
|
+
const host = (options.hostname ?? osHostname2)().split(".")[0]?.trim() ?? "";
|
|
2239
|
+
if (host)
|
|
2240
|
+
return host;
|
|
2241
|
+
const user = env.USER?.trim();
|
|
2242
|
+
return user || null;
|
|
2243
|
+
}
|
|
2244
|
+
function keychainFailureHint2(text) {
|
|
2245
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
2246
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
2247
|
+
return clean ? `: ${clean}` : "";
|
|
2248
|
+
}
|
|
2249
|
+
function readKeychainItem2(name, env, kind, options) {
|
|
2250
|
+
if (!SAFE_APP_SLUG2.test(name) || !keychainTierEnabled2(env, options))
|
|
2251
|
+
return null;
|
|
2252
|
+
const account = keychainAccount2(env, options);
|
|
2253
|
+
if (!account)
|
|
2254
|
+
return null;
|
|
2255
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX2}.${name}.${kind}`;
|
|
2256
|
+
const source = `keychain:${service}@${account}`;
|
|
2257
|
+
const run = options.run ?? defaultKeychainRunner2;
|
|
2258
|
+
let result;
|
|
2259
|
+
try {
|
|
2260
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
const reason = keychainFailureHint2(error instanceof Error ? error.message : String(error));
|
|
2263
|
+
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]);
|
|
2264
|
+
}
|
|
2265
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS2)
|
|
2266
|
+
return null;
|
|
2267
|
+
if (result.status !== 0) {
|
|
2268
|
+
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]);
|
|
2269
|
+
}
|
|
2270
|
+
const value = result.stdout.trim();
|
|
2271
|
+
if (!value) {
|
|
2272
|
+
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]);
|
|
2273
|
+
}
|
|
2274
|
+
return { value, source };
|
|
2275
|
+
}
|
|
2276
|
+
function keychainConfigValue2(name, env, options = {}) {
|
|
2277
|
+
return readKeychainItem2(name, env, "api-url", options);
|
|
2143
2278
|
}
|
|
2144
|
-
function
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2279
|
+
function snapshotClientEnvironment2(name, env) {
|
|
2280
|
+
const keys = clientTransportEnvKeys2(name);
|
|
2281
|
+
const ambient = isAmbientEnvironment2(env);
|
|
2282
|
+
const snapshot = Object.create(null);
|
|
2283
|
+
for (const key of [
|
|
2284
|
+
...keys.apiUrlKeys,
|
|
2285
|
+
...keys.apiKeyKeys,
|
|
2286
|
+
credentialOverrideEnvKey2(name),
|
|
2287
|
+
credentialPointerEnvKey2(name),
|
|
2288
|
+
CREDENTIAL_PROFILE_ENV_KEY2,
|
|
2289
|
+
"HOME",
|
|
2290
|
+
HASNA_HOME_ENV_KEY2,
|
|
2291
|
+
HASNA_CONFIG_HOME_ENV_KEY2,
|
|
2292
|
+
KEYCHAIN_STATION_ENV_KEY2,
|
|
2293
|
+
"USER"
|
|
2294
|
+
]) {
|
|
2295
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
2296
|
+
if (!descriptor)
|
|
2297
|
+
continue;
|
|
2298
|
+
if (!("value" in descriptor)) {
|
|
2299
|
+
throw new CredentialResolutionError2(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
2300
|
+
}
|
|
2301
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
2302
|
+
throw new CredentialResolutionError2(name, `${key} must be a string data property.`, [key]);
|
|
2303
|
+
}
|
|
2304
|
+
snapshot[key] = descriptor.value;
|
|
2148
2305
|
}
|
|
2306
|
+
if (ambient) {
|
|
2307
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT2, {
|
|
2308
|
+
value: true,
|
|
2309
|
+
enumerable: false,
|
|
2310
|
+
writable: false,
|
|
2311
|
+
configurable: false
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
return Object.freeze(snapshot);
|
|
2149
2315
|
}
|
|
2150
2316
|
function resolveCredential2(name, env, options = {}) {
|
|
2317
|
+
env = snapshotClientEnvironment2(name, env);
|
|
2151
2318
|
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
2152
2319
|
const diskPaths = credentialDiskSources2(name, env);
|
|
2153
|
-
|
|
2154
|
-
|
|
2320
|
+
if (options.apiKey !== undefined) {
|
|
2321
|
+
const explicitKey = options.apiKey.trim();
|
|
2322
|
+
if (!explicitKey) {
|
|
2323
|
+
throw new CredentialResolutionError2(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
2324
|
+
}
|
|
2155
2325
|
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
2156
2326
|
return sealCredential2({
|
|
2157
2327
|
apiKey: explicitKey,
|
|
2158
2328
|
tier: "argument",
|
|
2159
2329
|
source: "explicit apiKey argument",
|
|
2160
2330
|
deliberate: true,
|
|
2161
|
-
deprecated: false,
|
|
2162
2331
|
diskCandidates: diskPaths,
|
|
2163
2332
|
warning: null
|
|
2164
2333
|
});
|
|
2165
2334
|
}
|
|
2166
2335
|
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
2167
|
-
const overrideRaw = env[overrideKeyName];
|
|
2336
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
2168
2337
|
if (overrideRaw !== undefined) {
|
|
2169
2338
|
const override = overrideRaw.trim();
|
|
2170
2339
|
if (!override) {
|
|
@@ -2176,12 +2345,38 @@ function resolveCredential2(name, env, options = {}) {
|
|
|
2176
2345
|
tier: "override",
|
|
2177
2346
|
source: overrideKeyName,
|
|
2178
2347
|
deliberate: true,
|
|
2179
|
-
deprecated: false,
|
|
2180
2348
|
diskCandidates: diskPaths,
|
|
2181
2349
|
warning: null
|
|
2182
2350
|
});
|
|
2183
2351
|
}
|
|
2184
|
-
const
|
|
2352
|
+
const pointerKeyName = credentialPointerEnvKey2(name);
|
|
2353
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
2354
|
+
if (pointerRaw !== undefined) {
|
|
2355
|
+
const pointer = pointerRaw.trim();
|
|
2356
|
+
if (!pointer) {
|
|
2357
|
+
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]);
|
|
2358
|
+
}
|
|
2359
|
+
if (!VAULT_POINTER_SHAPE2.test(pointer)) {
|
|
2360
|
+
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]);
|
|
2361
|
+
}
|
|
2362
|
+
return sealCredential2({
|
|
2363
|
+
apiKey: "",
|
|
2364
|
+
pointerVaultKey: pointer,
|
|
2365
|
+
tier: "pointer",
|
|
2366
|
+
source: pointerKeyName,
|
|
2367
|
+
deliberate: true,
|
|
2368
|
+
diskCandidates: diskPaths,
|
|
2369
|
+
warning: null
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
2373
|
+
throw new CredentialResolutionError2(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
2374
|
+
}
|
|
2375
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY2) ? env[CREDENTIAL_PROFILE_ENV_KEY2] : undefined;
|
|
2376
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
2377
|
+
throw new CredentialResolutionError2(name, `${CREDENTIAL_PROFILE_ENV_KEY2} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY2]);
|
|
2378
|
+
}
|
|
2379
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
2185
2380
|
if (profile) {
|
|
2186
2381
|
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
2187
2382
|
if (!SAFE_PROFILE2.test(profile)) {
|
|
@@ -2197,7 +2392,6 @@ function resolveCredential2(name, env, options = {}) {
|
|
|
2197
2392
|
tier: "profile",
|
|
2198
2393
|
source: path,
|
|
2199
2394
|
deliberate: true,
|
|
2200
|
-
deprecated: false,
|
|
2201
2395
|
diskCandidates: paths,
|
|
2202
2396
|
warning: null
|
|
2203
2397
|
});
|
|
@@ -2205,51 +2399,105 @@ function resolveCredential2(name, env, options = {}) {
|
|
|
2205
2399
|
}
|
|
2206
2400
|
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
2401
|
}
|
|
2208
|
-
const
|
|
2402
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
2403
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
2404
|
+
if (blankEnv) {
|
|
2405
|
+
throw new CredentialResolutionError2(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
2406
|
+
}
|
|
2407
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
2408
|
+
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));
|
|
2409
|
+
}
|
|
2410
|
+
const envHit = firstEnvValue2(env, apiKeyKeys);
|
|
2411
|
+
const keychainHit = readKeychainItem2(name, env, "api-key", options.keychain ?? {});
|
|
2412
|
+
if (keychainHit) {
|
|
2413
|
+
assertUsableCredential2(name, keychainHit.source, keychainHit.value);
|
|
2414
|
+
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;
|
|
2415
|
+
return sealCredential2({
|
|
2416
|
+
apiKey: keychainHit.value,
|
|
2417
|
+
tier: "keychain",
|
|
2418
|
+
source: keychainHit.source,
|
|
2419
|
+
deliberate: false,
|
|
2420
|
+
diskCandidates: diskPaths,
|
|
2421
|
+
warning
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
const diskSourceList = credentialDiskSourceList2(name, env, null);
|
|
2425
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile2(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
2209
2426
|
if (diskHits.length > 0) {
|
|
2210
2427
|
const winner = diskHits[0];
|
|
2211
|
-
assertUsableCredential2(name, winner.path, winner.value);
|
|
2428
|
+
assertUsableCredential2(name, winner.src.path, winner.value);
|
|
2212
2429
|
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
|
-
})()
|
|
2430
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
2431
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
2218
2432
|
];
|
|
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;
|
|
2433
|
+
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
2434
|
return sealCredential2({
|
|
2221
2435
|
apiKey: winner.value,
|
|
2222
|
-
tier:
|
|
2223
|
-
source: winner.path,
|
|
2436
|
+
tier: winner.src.tier,
|
|
2437
|
+
source: winner.src.path,
|
|
2224
2438
|
deliberate: false,
|
|
2225
|
-
deprecated: false,
|
|
2226
2439
|
diskCandidates: diskPaths,
|
|
2227
2440
|
warning
|
|
2228
2441
|
});
|
|
2229
2442
|
}
|
|
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
|
-
}
|
|
2443
|
+
if (envHit) {
|
|
2444
|
+
assertUsableCredential2(name, envHit.key, envHit.value);
|
|
2241
2445
|
return sealCredential2({
|
|
2242
|
-
apiKey:
|
|
2243
|
-
tier: "
|
|
2244
|
-
source:
|
|
2446
|
+
apiKey: envHit.value,
|
|
2447
|
+
tier: "env",
|
|
2448
|
+
source: envHit.key,
|
|
2245
2449
|
deliberate: false,
|
|
2246
|
-
deprecated: true,
|
|
2247
2450
|
diskCandidates: diskPaths,
|
|
2248
|
-
warning:
|
|
2451
|
+
warning: null
|
|
2249
2452
|
});
|
|
2250
2453
|
}
|
|
2251
2454
|
return null;
|
|
2252
2455
|
}
|
|
2456
|
+
var SECRETS_PACKAGE_SPECIFIER2 = "@hasna/" + "secrets";
|
|
2457
|
+
var requireSecretsSdk2 = createRequire2(import.meta.url);
|
|
2458
|
+
async function completePointerCredential(name, pointerResolution, env = process.env) {
|
|
2459
|
+
const vaultKey = pointerResolution.pointerVaultKey;
|
|
2460
|
+
const pointerEnvKey = pointerResolution.source;
|
|
2461
|
+
if (!vaultKey) {
|
|
2462
|
+
throw new CredentialResolutionError2(name, `Pointer resolution from ${pointerEnvKey} carries no vault item key; this is a defect in the resolver.`, [pointerEnvKey]);
|
|
2463
|
+
}
|
|
2464
|
+
let secretsSdk;
|
|
2465
|
+
try {
|
|
2466
|
+
secretsSdk = requireSecretsSdk2(SECRETS_PACKAGE_SPECIFIER2);
|
|
2467
|
+
} catch {
|
|
2468
|
+
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]);
|
|
2469
|
+
}
|
|
2470
|
+
let client;
|
|
2471
|
+
try {
|
|
2472
|
+
client = secretsSdk.createSecretsClientFromEnv(env);
|
|
2473
|
+
} catch {
|
|
2474
|
+
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]);
|
|
2475
|
+
}
|
|
2476
|
+
let secret;
|
|
2477
|
+
try {
|
|
2478
|
+
secret = await client.getSecret({ key: vaultKey });
|
|
2479
|
+
} catch {
|
|
2480
|
+
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]);
|
|
2481
|
+
}
|
|
2482
|
+
const value = secret.value;
|
|
2483
|
+
if (!value) {
|
|
2484
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} resolved vault item '${vaultKey}', but it holds no value. A vault pointer is TERMINAL.`, [pointerEnvKey]);
|
|
2485
|
+
}
|
|
2486
|
+
assertUsableCredential2(name, `${pointerEnvKey} -> vault:${vaultKey}`, value);
|
|
2487
|
+
return sealCredential2({
|
|
2488
|
+
apiKey: value,
|
|
2489
|
+
tier: "pointer",
|
|
2490
|
+
source: `${pointerEnvKey} -> vault:${vaultKey}`,
|
|
2491
|
+
deliberate: true,
|
|
2492
|
+
diskCandidates: pointerResolution.diskCandidates,
|
|
2493
|
+
warning: null
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN2 = "https://api.hasna.com";
|
|
2497
|
+
var DEFAULT_AUTHORITY_SOURCE2 = "default";
|
|
2498
|
+
function defaultFleetGatewayBaseUrl2(name) {
|
|
2499
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN2}/${validateAppSlug2(name)}`;
|
|
2500
|
+
}
|
|
2253
2501
|
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
2254
2502
|
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2255
2503
|
function isValidDnsDomain2(value) {
|
|
@@ -2258,21 +2506,11 @@ function isValidDnsDomain2(value) {
|
|
|
2258
2506
|
}
|
|
2259
2507
|
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
2260
2508
|
}
|
|
2261
|
-
function
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
const value = raw?.trim();
|
|
2265
|
-
if (value)
|
|
2266
|
-
return { key, value: options.preserveRaw ? raw : value };
|
|
2267
|
-
}
|
|
2268
|
-
return null;
|
|
2269
|
-
}
|
|
2270
|
-
function firstEnvDefinedKey2(env, keys) {
|
|
2271
|
-
for (const key of keys) {
|
|
2272
|
-
if (env[key] !== undefined)
|
|
2273
|
-
return key;
|
|
2509
|
+
function validateAppSlug2(name) {
|
|
2510
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN2.test(name)) {
|
|
2511
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
2274
2512
|
}
|
|
2275
|
-
return
|
|
2513
|
+
return name;
|
|
2276
2514
|
}
|
|
2277
2515
|
function rawAuthority2(value) {
|
|
2278
2516
|
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
@@ -2375,106 +2613,104 @@ function toV1BaseUrl2(apiUrl) {
|
|
|
2375
2613
|
url.pathname = `${path}/v1`;
|
|
2376
2614
|
return url.toString().replace(/\/+$/, "");
|
|
2377
2615
|
}
|
|
2378
|
-
|
|
2616
|
+
class ClientTransportConfigurationError2 extends Error {
|
|
2617
|
+
appName;
|
|
2618
|
+
sources;
|
|
2619
|
+
constructor(appName, message, sources = []) {
|
|
2620
|
+
super(message);
|
|
2621
|
+
this.name = "ClientTransportConfigurationError";
|
|
2622
|
+
this.appName = appName;
|
|
2623
|
+
this.sources = Object.freeze([...sources]);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
function resolveClientTransportSnapshot2(name, env = process.env, options = {}) {
|
|
2627
|
+
env = snapshotClientEnvironment2(name, env);
|
|
2379
2628
|
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
|
-
};
|
|
2629
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
2630
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
2631
|
+
if (blankUrl) {
|
|
2632
|
+
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]);
|
|
2633
|
+
}
|
|
2634
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN2.test(entry.raw));
|
|
2635
|
+
if (controlledUrl) {
|
|
2636
|
+
throw new ClientTransportConfigurationError2(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
2637
|
+
}
|
|
2638
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
2639
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
2640
|
+
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));
|
|
2641
|
+
}
|
|
2642
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
2643
|
+
const keychainUrlHit = keychainConfigValue2(name, env, options.credentials?.keychain);
|
|
2644
|
+
const diskConfigUrlHit = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
2645
|
+
if (diskConfigUrlHit?.unusable) {
|
|
2646
|
+
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]);
|
|
2647
|
+
}
|
|
2648
|
+
const urlCandidates = [
|
|
2649
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
2650
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
2651
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
2652
|
+
];
|
|
2653
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
2654
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
2655
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
2656
|
+
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
2657
|
}
|
|
2416
|
-
|
|
2417
|
-
|
|
2658
|
+
const warnings = [];
|
|
2659
|
+
if (configuredUrl && !envUrlHit) {
|
|
2660
|
+
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
2661
|
}
|
|
2419
2662
|
const credential = resolveCredential2(name, env, options.credentials);
|
|
2420
2663
|
if (!credential) {
|
|
2421
2664
|
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
|
-
};
|
|
2665
|
+
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`;
|
|
2666
|
+
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.`);
|
|
2667
|
+
throw new ClientTransportConfigurationError2(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
2434
2668
|
}
|
|
2435
2669
|
if (credential.warning)
|
|
2436
2670
|
warnings.push(credential.warning);
|
|
2671
|
+
let urlHit;
|
|
2672
|
+
if (configuredUrl) {
|
|
2673
|
+
urlHit = configuredUrl;
|
|
2674
|
+
} else {
|
|
2675
|
+
try {
|
|
2676
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE2, value: defaultFleetGatewayBaseUrl2(name) };
|
|
2677
|
+
} catch (error) {
|
|
2678
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2679
|
+
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]]);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2437
2682
|
const apiUrlSource = urlHit.key;
|
|
2438
2683
|
let baseUrl;
|
|
2439
2684
|
try {
|
|
2440
2685
|
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
2441
2686
|
} catch (error) {
|
|
2442
2687
|
const message = error instanceof Error ? error.message : String(error);
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2688
|
+
throw new ClientTransportConfigurationError2(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
2689
|
+
}
|
|
2690
|
+
return {
|
|
2691
|
+
resolution: {
|
|
2692
|
+
transport: "http",
|
|
2446
2693
|
transportSource: urlHit.key,
|
|
2447
|
-
baseUrl
|
|
2448
|
-
apiUrlSource
|
|
2694
|
+
baseUrl,
|
|
2695
|
+
apiUrlSource,
|
|
2449
2696
|
apiKeyPresent: true,
|
|
2450
2697
|
apiKeySource: credential.source,
|
|
2451
2698
|
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
|
|
2699
|
+
misconfigured: false,
|
|
2700
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2701
|
+
},
|
|
2702
|
+
credential
|
|
2466
2703
|
};
|
|
2467
2704
|
}
|
|
2468
2705
|
function credentialDiskSourcesForMessage2(name, env) {
|
|
2469
2706
|
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>";
|
|
2707
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
2471
2708
|
}
|
|
2472
2709
|
|
|
2473
|
-
class
|
|
2710
|
+
class HasnaHttpError extends Error {
|
|
2474
2711
|
status;
|
|
2475
2712
|
method;
|
|
2476
2713
|
path;
|
|
2477
|
-
body;
|
|
2478
2714
|
credentialSource;
|
|
2479
2715
|
credentialTier;
|
|
2480
2716
|
constructor(method, path, status, body, credential) {
|
|
@@ -2484,31 +2720,46 @@ class HasnaHttpError2 extends Error {
|
|
|
2484
2720
|
this.status = status;
|
|
2485
2721
|
this.method = method;
|
|
2486
2722
|
this.path = path;
|
|
2487
|
-
this
|
|
2723
|
+
Object.defineProperty(this, "body", {
|
|
2724
|
+
value: body,
|
|
2725
|
+
enumerable: status !== 401 && status !== 403,
|
|
2726
|
+
writable: false,
|
|
2727
|
+
configurable: false
|
|
2728
|
+
});
|
|
2488
2729
|
this.credentialSource = credential?.source ?? null;
|
|
2489
2730
|
this.credentialTier = credential?.tier ?? null;
|
|
2490
2731
|
}
|
|
2491
2732
|
}
|
|
2492
|
-
function
|
|
2733
|
+
function currentCredential(name, apiKey) {
|
|
2493
2734
|
if (typeof apiKey === "function") {
|
|
2494
|
-
return
|
|
2735
|
+
return validateAndSealResolvedCredential(name, apiKey());
|
|
2736
|
+
}
|
|
2737
|
+
return explicitCredential(name, apiKey);
|
|
2738
|
+
}
|
|
2739
|
+
async function resolveRequestCredential(name, apiKey, env = process.env) {
|
|
2740
|
+
const resolved = currentCredential(name, apiKey);
|
|
2741
|
+
if (resolved.tier === "pointer") {
|
|
2742
|
+
return completePointerCredential(name, resolved, env);
|
|
2495
2743
|
}
|
|
2496
|
-
return
|
|
2744
|
+
return resolved;
|
|
2497
2745
|
}
|
|
2498
|
-
function
|
|
2746
|
+
function authFailureGuidance(credential) {
|
|
2499
2747
|
const origin = `The API key for this request came from ${credential.source}`;
|
|
2500
2748
|
if (credential.deliberate) {
|
|
2501
|
-
const remedy = credential.source ===
|
|
2749
|
+
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
2750
|
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
2751
|
}
|
|
2504
|
-
if (credential.
|
|
2752
|
+
if (credential.tier === "env") {
|
|
2505
2753
|
const target = credential.diskCandidates[0];
|
|
2506
|
-
const remedy = target ? `
|
|
2507
|
-
return `${origin}, a variable in this process's environment
|
|
2754
|
+
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.`;
|
|
2755
|
+
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}`;
|
|
2756
|
+
}
|
|
2757
|
+
if (credential.tier === "keychain") {
|
|
2758
|
+
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
2759
|
}
|
|
2509
2760
|
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
2761
|
}
|
|
2511
|
-
var
|
|
2762
|
+
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
2512
2763
|
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2513
2764
|
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2514
2765
|
"host",
|
|
@@ -2517,7 +2768,7 @@ var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
|
2517
2768
|
"x-forwarded-host",
|
|
2518
2769
|
"x-original-host"
|
|
2519
2770
|
]);
|
|
2520
|
-
function
|
|
2771
|
+
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
2521
2772
|
if (!headers)
|
|
2522
2773
|
return;
|
|
2523
2774
|
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
@@ -2525,7 +2776,7 @@ function assertNoAuthorityOverrideHeaders2(headers, source) {
|
|
|
2525
2776
|
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
2526
2777
|
}
|
|
2527
2778
|
}
|
|
2528
|
-
function
|
|
2779
|
+
function appendQuery(path, query) {
|
|
2529
2780
|
if (!query)
|
|
2530
2781
|
return path;
|
|
2531
2782
|
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
@@ -2546,12 +2797,12 @@ function appendQuery2(path, query) {
|
|
|
2546
2797
|
return path;
|
|
2547
2798
|
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
2548
2799
|
}
|
|
2549
|
-
var
|
|
2550
|
-
function
|
|
2800
|
+
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2801
|
+
function createHasnaHttpTransportInternal(options, requestBindingProvider) {
|
|
2551
2802
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2552
2803
|
const base = toV1BaseUrl2(options.baseUrl);
|
|
2553
2804
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2554
|
-
const sleep = options.sleepImpl ??
|
|
2805
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
2555
2806
|
const defaultRetry = options.retry;
|
|
2556
2807
|
function resolveRetry(callRetry) {
|
|
2557
2808
|
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
@@ -2562,12 +2813,12 @@ function createHasnaHttpTransport2(options) {
|
|
|
2562
2813
|
retries: r.retries ?? 2,
|
|
2563
2814
|
baseDelayMs: r.baseDelayMs ?? 200,
|
|
2564
2815
|
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
2565
|
-
retryStatuses: r.retryStatuses ?? [...
|
|
2816
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
2566
2817
|
};
|
|
2567
2818
|
}
|
|
2568
2819
|
async function once(method, rel, url, body, opts, credential) {
|
|
2569
|
-
|
|
2570
|
-
|
|
2820
|
+
assertNoAuthorityOverrideHeaders(options.headers, "transport");
|
|
2821
|
+
assertNoAuthorityOverrideHeaders(opts.headers, "request");
|
|
2571
2822
|
const headers = {
|
|
2572
2823
|
"x-api-key": credential.apiKey,
|
|
2573
2824
|
Authorization: `Bearer ${credential.apiKey}`,
|
|
@@ -2609,13 +2860,20 @@ function createHasnaHttpTransport2(options) {
|
|
|
2609
2860
|
if (opts.signal)
|
|
2610
2861
|
opts.signal.removeEventListener("abort", onAbort);
|
|
2611
2862
|
}
|
|
2612
|
-
const
|
|
2863
|
+
const authenticationFailure = response.status === 401 || response.status === 403;
|
|
2613
2864
|
let parsed = undefined;
|
|
2614
|
-
if (
|
|
2865
|
+
if (authenticationFailure) {
|
|
2615
2866
|
try {
|
|
2616
|
-
|
|
2617
|
-
} catch {
|
|
2618
|
-
|
|
2867
|
+
await response.body?.cancel();
|
|
2868
|
+
} catch {}
|
|
2869
|
+
} else {
|
|
2870
|
+
const text = await response.text();
|
|
2871
|
+
if (text.length > 0) {
|
|
2872
|
+
try {
|
|
2873
|
+
parsed = JSON.parse(text);
|
|
2874
|
+
} catch {
|
|
2875
|
+
parsed = text;
|
|
2876
|
+
}
|
|
2619
2877
|
}
|
|
2620
2878
|
}
|
|
2621
2879
|
if (!response.ok) {
|
|
@@ -2623,34 +2881,38 @@ function createHasnaHttpTransport2(options) {
|
|
|
2623
2881
|
return {
|
|
2624
2882
|
ok: false,
|
|
2625
2883
|
retryable: false,
|
|
2626
|
-
error: new
|
|
2884
|
+
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
2627
2885
|
};
|
|
2628
2886
|
}
|
|
2629
|
-
if (
|
|
2887
|
+
if (authenticationFailure) {
|
|
2630
2888
|
return {
|
|
2631
2889
|
ok: false,
|
|
2632
2890
|
retryable: false,
|
|
2633
|
-
error: new
|
|
2891
|
+
error: new HasnaHttpError(method, rel, response.status, undefined, {
|
|
2634
2892
|
source: credential.source,
|
|
2635
2893
|
tier: credential.tier,
|
|
2636
|
-
guidance:
|
|
2894
|
+
guidance: authFailureGuidance(credential)
|
|
2637
2895
|
})
|
|
2638
2896
|
};
|
|
2639
2897
|
}
|
|
2640
2898
|
const retry = resolveRetry(opts.retry);
|
|
2641
2899
|
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
2642
|
-
return { ok: false, retryable, error: new
|
|
2900
|
+
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
2643
2901
|
}
|
|
2644
2902
|
return { ok: true, value: parsed };
|
|
2645
2903
|
}
|
|
2646
2904
|
async function request(method, path, body, opts = {}) {
|
|
2647
2905
|
const upper = method.toUpperCase();
|
|
2648
|
-
const rel =
|
|
2649
|
-
const url = `${base}${rel}`;
|
|
2906
|
+
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2650
2907
|
const retry = resolveRetry(opts.retry);
|
|
2651
2908
|
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
2652
2909
|
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2653
|
-
const
|
|
2910
|
+
const binding = requestBindingProvider ? await requestBindingProvider() : {
|
|
2911
|
+
baseUrl: base,
|
|
2912
|
+
credential: await resolveRequestCredential(options.name, options.apiKey)
|
|
2913
|
+
};
|
|
2914
|
+
const url = `${binding.baseUrl}${rel}`;
|
|
2915
|
+
const credential = binding.credential;
|
|
2654
2916
|
let last = null;
|
|
2655
2917
|
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2656
2918
|
const result = await once(upper, rel, url, body, opts, credential);
|
|
@@ -2676,34 +2938,43 @@ function createHasnaHttpTransport2(options) {
|
|
|
2676
2938
|
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
2677
2939
|
};
|
|
2678
2940
|
}
|
|
2679
|
-
function
|
|
2941
|
+
function createClientTransport(name, env = process.env, overrides) {
|
|
2680
2942
|
const credentialOptions = overrides?.credentials;
|
|
2681
|
-
const
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
if (
|
|
2691
|
-
throw new
|
|
2692
|
-
}
|
|
2693
|
-
|
|
2943
|
+
const snapshotOptions = { ...credentialOptions ? { credentials: credentialOptions } : {} };
|
|
2944
|
+
const resolution = resolveClientTransportSnapshot2(name, env, snapshotOptions).resolution;
|
|
2945
|
+
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;
|
|
2946
|
+
const unstableConfiguration = () => new ClientTransportConfigurationError2(name, "The configured service authority or credential changed while a request was being prepared; no authenticated request was sent.");
|
|
2947
|
+
const requestBindingProvider = async () => {
|
|
2948
|
+
const first = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
2949
|
+
const reviewed = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
2950
|
+
if (!sameBinding(first, reviewed))
|
|
2951
|
+
throw unstableConfiguration();
|
|
2952
|
+
if (reviewed.resolution.baseUrl !== resolution.baseUrl) {
|
|
2953
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
2954
|
+
}
|
|
2955
|
+
const credential = await resolveRequestCredential(name, () => reviewed.credential, env);
|
|
2956
|
+
const immediatelyBeforeDispatch = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
2957
|
+
if (!sameBinding(reviewed, immediatelyBeforeDispatch))
|
|
2958
|
+
throw unstableConfiguration();
|
|
2959
|
+
if (immediatelyBeforeDispatch.resolution.baseUrl !== resolution.baseUrl) {
|
|
2960
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
2961
|
+
}
|
|
2962
|
+
return { baseUrl: immediatelyBeforeDispatch.resolution.baseUrl, credential };
|
|
2694
2963
|
};
|
|
2695
2964
|
return {
|
|
2696
2965
|
transport: "http",
|
|
2697
|
-
client:
|
|
2966
|
+
client: createHasnaHttpTransportInternal({
|
|
2698
2967
|
name,
|
|
2699
2968
|
baseUrl: resolution.baseUrl,
|
|
2700
|
-
apiKey:
|
|
2969
|
+
apiKey: () => {
|
|
2970
|
+
throw new Error("The authenticated request binding provider was not invoked.");
|
|
2971
|
+
},
|
|
2701
2972
|
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2702
2973
|
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2703
2974
|
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2704
2975
|
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2705
2976
|
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2706
|
-
}),
|
|
2977
|
+
}, requestBindingProvider),
|
|
2707
2978
|
resolution
|
|
2708
2979
|
};
|
|
2709
2980
|
}
|
|
@@ -2807,41 +3078,68 @@ function createHasnaStorageClient(name, transport) {
|
|
|
2807
3078
|
};
|
|
2808
3079
|
}
|
|
2809
3080
|
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 };
|
|
3081
|
+
const wired = createClientTransport(name, env, overrides);
|
|
3082
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
2815
3083
|
}
|
|
2816
3084
|
|
|
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
|
-
};
|
|
3085
|
+
// src/lib/local-opt-in.ts
|
|
3086
|
+
var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
|
|
3087
|
+
"HASNA_RECORDINGS_LOCAL",
|
|
3088
|
+
"RECORDINGS_LOCAL"
|
|
3089
|
+
];
|
|
3090
|
+
function isRecordingsLocalOptIn(env = process.env) {
|
|
3091
|
+
return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
2828
3092
|
}
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
3093
|
+
var RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
|
|
3094
|
+
"RECORDINGS_API_URL",
|
|
3095
|
+
"RECORDINGS_API_KEY"
|
|
3096
|
+
];
|
|
3097
|
+
function recordingsAuthorityEnvKeys() {
|
|
3098
|
+
const keys = clientTransportEnvKeys("recordings");
|
|
3099
|
+
return [
|
|
3100
|
+
...keys.apiUrlKeys,
|
|
3101
|
+
...keys.apiKeyKeys,
|
|
3102
|
+
credentialOverrideEnvKey("recordings"),
|
|
3103
|
+
credentialPointerEnvKey("recordings"),
|
|
3104
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
3105
|
+
].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
|
|
3106
|
+
}
|
|
3107
|
+
function hasRecordingsEnvAuthorityIntent(env = process.env) {
|
|
3108
|
+
return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
3109
|
+
}
|
|
3110
|
+
function selectsRecordingsLocalStore(env = process.env) {
|
|
3111
|
+
return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
|
|
3112
|
+
}
|
|
3113
|
+
function recordingsResolverEnv(env) {
|
|
3114
|
+
const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
|
|
3115
|
+
const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
3116
|
+
const removed = new Set([...carved, ...blanks]);
|
|
3117
|
+
if (removed.size === 0)
|
|
3118
|
+
return env;
|
|
3119
|
+
const next = { ...env };
|
|
3120
|
+
for (const key of removed)
|
|
3121
|
+
delete next[key];
|
|
3122
|
+
return next;
|
|
3123
|
+
}
|
|
3124
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
3125
|
+
function isAmbientRecordingsEnv(env) {
|
|
3126
|
+
if (typeof process !== "undefined" && env === process.env) {
|
|
3127
|
+
return true;
|
|
3128
|
+
}
|
|
3129
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
2836
3130
|
}
|
|
2837
|
-
function
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
3131
|
+
function recordingsResolverInputs(env, credentials = {}) {
|
|
3132
|
+
const normalised = recordingsResolverEnv(env);
|
|
3133
|
+
if (normalised === env)
|
|
3134
|
+
return { env: normalised, credentials };
|
|
3135
|
+
const keychain = { ...credentials.keychain };
|
|
3136
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
3137
|
+
keychain.enabled = isAmbientRecordingsEnv(env);
|
|
2842
3138
|
}
|
|
2843
|
-
return
|
|
3139
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
2844
3140
|
}
|
|
3141
|
+
|
|
3142
|
+
// src/http/client.ts
|
|
2845
3143
|
function toV1BaseUrl3(apiUrl) {
|
|
2846
3144
|
const url = new URL(apiUrl);
|
|
2847
3145
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -2855,69 +3153,8 @@ function toV1BaseUrl3(apiUrl) {
|
|
|
2855
3153
|
url.hash = "";
|
|
2856
3154
|
return url.toString().replace(/\/+$/, "");
|
|
2857
3155
|
}
|
|
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
3156
|
|
|
2920
|
-
class
|
|
3157
|
+
class HasnaHttpError2 extends Error {
|
|
2921
3158
|
status;
|
|
2922
3159
|
method;
|
|
2923
3160
|
path;
|
|
@@ -2931,7 +3168,7 @@ class HasnaHttpError3 extends Error {
|
|
|
2931
3168
|
this.body = body;
|
|
2932
3169
|
}
|
|
2933
3170
|
}
|
|
2934
|
-
function
|
|
3171
|
+
function appendQuery2(path, query) {
|
|
2935
3172
|
if (!query)
|
|
2936
3173
|
return path;
|
|
2937
3174
|
const params = new URLSearchParams;
|
|
@@ -2949,12 +3186,12 @@ function appendQuery3(path, query) {
|
|
|
2949
3186
|
}
|
|
2950
3187
|
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
2951
3188
|
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2952
|
-
var
|
|
3189
|
+
var defaultSleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2953
3190
|
function createHttpTransport(options) {
|
|
2954
3191
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2955
3192
|
const base = options.baseUrl.replace(/\/+$/, "");
|
|
2956
3193
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2957
|
-
const sleep = options.sleepImpl ??
|
|
3194
|
+
const sleep = options.sleepImpl ?? defaultSleep2;
|
|
2958
3195
|
async function once(method, rel, url, body, opts) {
|
|
2959
3196
|
const headers = {
|
|
2960
3197
|
"x-api-key": options.apiKey,
|
|
@@ -3002,13 +3239,13 @@ function createHttpTransport(options) {
|
|
|
3002
3239
|
}
|
|
3003
3240
|
}
|
|
3004
3241
|
if (!response.ok) {
|
|
3005
|
-
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new
|
|
3242
|
+
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new HasnaHttpError2(method, rel, response.status, parsed) };
|
|
3006
3243
|
}
|
|
3007
3244
|
return { ok: true, value: parsed };
|
|
3008
3245
|
}
|
|
3009
3246
|
async function request(method, path, body, opts = {}) {
|
|
3010
3247
|
const upper = method.toUpperCase();
|
|
3011
|
-
const rel =
|
|
3248
|
+
const rel = appendQuery2(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
3012
3249
|
const url = `${base}${rel}`;
|
|
3013
3250
|
const methodRetryable = IDEMPOTENT.has(upper) || Boolean(opts.idempotencyKey);
|
|
3014
3251
|
const maxRetries = opts.retries ?? 2;
|
|
@@ -3073,7 +3310,7 @@ function createStorageClient(name, transport) {
|
|
|
3073
3310
|
try {
|
|
3074
3311
|
return await transport.get(ep(resource, id));
|
|
3075
3312
|
} catch (error) {
|
|
3076
|
-
if (error instanceof
|
|
3313
|
+
if (error instanceof HasnaHttpError2 && error.status === 404)
|
|
3077
3314
|
return null;
|
|
3078
3315
|
throw error;
|
|
3079
3316
|
}
|
|
@@ -3089,46 +3326,278 @@ function createStorageClient(name, transport) {
|
|
|
3089
3326
|
try {
|
|
3090
3327
|
await transport.del(ep(resource, id));
|
|
3091
3328
|
} catch (error) {
|
|
3092
|
-
if (error instanceof
|
|
3329
|
+
if (error instanceof HasnaHttpError2 && error.status === 404)
|
|
3093
3330
|
return;
|
|
3094
3331
|
throw error;
|
|
3095
3332
|
}
|
|
3096
3333
|
}
|
|
3097
3334
|
};
|
|
3098
3335
|
}
|
|
3099
|
-
function
|
|
3100
|
-
const
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
transport: "http",
|
|
3109
|
-
requested: "http",
|
|
3110
|
-
modeSource: resolution.modeSource === "default" ? "auto:api-url+seam-credential" : resolution.modeSource,
|
|
3111
|
-
baseUrl: wired2.resolution.baseUrl,
|
|
3112
|
-
apiKeyPresent: true,
|
|
3113
|
-
misconfigured: false,
|
|
3114
|
-
warning: null
|
|
3115
|
-
}
|
|
3116
|
-
};
|
|
3336
|
+
function throwRecordingsAuthorityFailure(error) {
|
|
3337
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3338
|
+
const name = error instanceof Error ? error.name : "";
|
|
3339
|
+
if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
|
|
3340
|
+
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 });
|
|
3341
|
+
}
|
|
3342
|
+
if (/no API key could be resolved/.test(message)) {
|
|
3343
|
+
if (/is not set and no API key could be resolved/.test(message)) {
|
|
3344
|
+
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 });
|
|
3117
3345
|
}
|
|
3118
|
-
throw new Error(
|
|
3346
|
+
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 });
|
|
3119
3347
|
}
|
|
3120
|
-
|
|
3121
|
-
|
|
3348
|
+
throw new Error(`REMOTE_API_URL_INVALID: ${message} local SQLite fallback is disabled`, { cause: error });
|
|
3349
|
+
}
|
|
3350
|
+
function toRecordsAuthority(resolution) {
|
|
3351
|
+
return {
|
|
3352
|
+
transport: resolution.transport,
|
|
3353
|
+
transportSource: resolution.transportSource,
|
|
3354
|
+
baseUrl: resolution.baseUrl,
|
|
3355
|
+
apiUrlSource: resolution.apiUrlSource,
|
|
3356
|
+
apiKeyPresent: resolution.apiKeyPresent,
|
|
3357
|
+
apiKeySource: resolution.apiKeySource,
|
|
3358
|
+
apiKeyTier: resolution.apiKeyTier,
|
|
3359
|
+
misconfigured: resolution.misconfigured,
|
|
3360
|
+
warning: resolution.warning
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
function resolveRecordingsTransport(env = process.env, options = {}) {
|
|
3364
|
+
if (selectsRecordingsLocalStore(env)) {
|
|
3365
|
+
return { transport: "sqlite", selected: false, source: "local-opt-in", authority: null };
|
|
3122
3366
|
}
|
|
3123
|
-
const
|
|
3124
|
-
|
|
3125
|
-
|
|
3367
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
3368
|
+
let resolution;
|
|
3369
|
+
try {
|
|
3370
|
+
resolution = resolveClientTransport("recordings", resolverInputs.env, {
|
|
3371
|
+
credentials: resolverInputs.credentials
|
|
3372
|
+
});
|
|
3373
|
+
} catch (error) {
|
|
3374
|
+
throwRecordingsAuthorityFailure(error);
|
|
3126
3375
|
}
|
|
3127
|
-
return {
|
|
3376
|
+
return {
|
|
3377
|
+
transport: "http",
|
|
3378
|
+
selected: true,
|
|
3379
|
+
source: `${resolution.apiKeySource ?? resolution.apiKeyTier}+${resolution.apiUrlSource ?? "default"}`,
|
|
3380
|
+
authority: toRecordsAuthority(resolution)
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
function getRecordingsTransportStatus(env = process.env, options = {}) {
|
|
3384
|
+
let resolution;
|
|
3385
|
+
try {
|
|
3386
|
+
resolution = resolveRecordingsTransport(env, options);
|
|
3387
|
+
} catch (error) {
|
|
3388
|
+
const issue = error instanceof Error ? error.message : String(error);
|
|
3389
|
+
const envKeys = clientTransportEnvKeys("recordings");
|
|
3390
|
+
const declared = (keys) => keys.some((key) => (env[key] ?? "").trim() !== "");
|
|
3391
|
+
return {
|
|
3392
|
+
selected: true,
|
|
3393
|
+
ok: false,
|
|
3394
|
+
transport: "invalid",
|
|
3395
|
+
api_url_configured: declared(envKeys.apiUrlKeys),
|
|
3396
|
+
api_key_configured: declared(envKeys.apiKeyKeys),
|
|
3397
|
+
api_url_source: null,
|
|
3398
|
+
api_key_source: null,
|
|
3399
|
+
api_key_tier: null,
|
|
3400
|
+
v1_base_url: null,
|
|
3401
|
+
issues: [issue],
|
|
3402
|
+
local_fallback: false
|
|
3403
|
+
};
|
|
3404
|
+
}
|
|
3405
|
+
if (!resolution.selected) {
|
|
3406
|
+
return {
|
|
3407
|
+
selected: false,
|
|
3408
|
+
ok: true,
|
|
3409
|
+
transport: resolution.transport,
|
|
3410
|
+
api_url_configured: false,
|
|
3411
|
+
api_key_configured: false,
|
|
3412
|
+
api_url_source: null,
|
|
3413
|
+
api_key_source: null,
|
|
3414
|
+
api_key_tier: null,
|
|
3415
|
+
v1_base_url: null,
|
|
3416
|
+
issues: [],
|
|
3417
|
+
local_fallback: false
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
const authority = resolution.authority;
|
|
3421
|
+
return {
|
|
3422
|
+
selected: true,
|
|
3423
|
+
ok: true,
|
|
3424
|
+
transport: resolution.transport,
|
|
3425
|
+
api_url_configured: authority.apiUrlSource !== null && authority.apiUrlSource !== "default",
|
|
3426
|
+
api_key_configured: authority.apiKeyPresent,
|
|
3427
|
+
api_url_source: authority.apiUrlSource,
|
|
3428
|
+
api_key_source: authority.apiKeySource,
|
|
3429
|
+
api_key_tier: authority.apiKeyTier,
|
|
3430
|
+
v1_base_url: authority.baseUrl,
|
|
3431
|
+
issues: [],
|
|
3432
|
+
local_fallback: false
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
function resolveRecordingsCloudClient(env = process.env, options = {}) {
|
|
3436
|
+
if (selectsRecordingsLocalStore(env))
|
|
3437
|
+
return null;
|
|
3438
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
3439
|
+
let resolved;
|
|
3440
|
+
try {
|
|
3441
|
+
resolved = resolveStorageClient("recordings", resolverInputs.env, {
|
|
3442
|
+
fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
|
|
3443
|
+
credentials: resolverInputs.credentials
|
|
3444
|
+
});
|
|
3445
|
+
} catch (error) {
|
|
3446
|
+
throwRecordingsAuthorityFailure(error);
|
|
3447
|
+
}
|
|
3448
|
+
const client = resolved.client;
|
|
3449
|
+
return client;
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
// src/store.ts
|
|
3453
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
3454
|
+
|
|
3455
|
+
// src/lib/audio-artifact-storage.ts
|
|
3456
|
+
import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
|
|
3457
|
+
import { createHash } from "crypto";
|
|
3458
|
+
import { readFile } from "fs/promises";
|
|
3459
|
+
import { extname } from "path";
|
|
3460
|
+
var SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
3461
|
+
var DEFAULT_S3_PREFIX = "recordings";
|
|
3462
|
+
var AUDIO_CONTENT_TYPES = {
|
|
3463
|
+
wav: "audio/wav",
|
|
3464
|
+
mp3: "audio/mpeg",
|
|
3465
|
+
m4a: "audio/mp4",
|
|
3466
|
+
aac: "audio/aac",
|
|
3467
|
+
webm: "audio/webm",
|
|
3468
|
+
ogg: "audio/ogg",
|
|
3469
|
+
flac: "audio/flac"
|
|
3470
|
+
};
|
|
3471
|
+
function contentTypeForExtension(extension) {
|
|
3472
|
+
return AUDIO_CONTENT_TYPES[extension.toLowerCase()] ?? "application/octet-stream";
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
class AudioArtifactStorage {
|
|
3476
|
+
bucket;
|
|
3477
|
+
prefix;
|
|
3478
|
+
s3;
|
|
3479
|
+
constructor(options = {}) {
|
|
3480
|
+
this.bucket = options.bucket;
|
|
3481
|
+
this.prefix = (options.prefix || DEFAULT_S3_PREFIX).replace(/^\/+|\/+$/g, "");
|
|
3482
|
+
this.s3 = this.bucket ? options.client ?? new AwsS3Client({ region: options.region || process.env.AWS_REGION || "us-east-1" }) : undefined;
|
|
3483
|
+
}
|
|
3484
|
+
get usesS3() {
|
|
3485
|
+
return Boolean(this.bucket);
|
|
3486
|
+
}
|
|
3487
|
+
objectKeyFor(recordingId, sha256, extension) {
|
|
3488
|
+
assertSha256(sha256);
|
|
3489
|
+
const safeExtension = extension.toLowerCase().match(/^[a-z0-9]+/)?.[0] || "bin";
|
|
3490
|
+
return `${this.prefix}/${recordingId}/${sha256}.${safeExtension}`;
|
|
3491
|
+
}
|
|
3492
|
+
async uploadAudio(recordingId, audioPath) {
|
|
3493
|
+
if (!this.bucket || !this.s3)
|
|
3494
|
+
return null;
|
|
3495
|
+
const bytes = await readFileBytes(audioPath);
|
|
3496
|
+
if (!bytes)
|
|
3497
|
+
return null;
|
|
3498
|
+
const sha256 = sha256Hex(bytes);
|
|
3499
|
+
const extension = extensionOf(audioPath);
|
|
3500
|
+
const key = this.objectKeyFor(recordingId, sha256, extension);
|
|
3501
|
+
await this.s3.send(new PutObjectCommand({
|
|
3502
|
+
Bucket: this.bucket,
|
|
3503
|
+
Key: key,
|
|
3504
|
+
Body: bytes,
|
|
3505
|
+
ContentType: contentTypeForExtension(extension)
|
|
3506
|
+
}));
|
|
3507
|
+
return { objectKey: key, sha256, bytes: bytes.byteLength };
|
|
3508
|
+
}
|
|
3509
|
+
async readAudio(objectKey) {
|
|
3510
|
+
if (!this.bucket || !this.s3)
|
|
3511
|
+
return null;
|
|
3512
|
+
try {
|
|
3513
|
+
const response = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
|
3514
|
+
return await bodyToBytes(response.Body);
|
|
3515
|
+
} catch {
|
|
3516
|
+
return null;
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
function resolveAudioArtifactStorage(env = process.env, client) {
|
|
3521
|
+
const bucket = env.HASNA_RECORDINGS_S3_BUCKET || env.RECORDINGS_S3_BUCKET || undefined;
|
|
3522
|
+
const prefix = env.HASNA_RECORDINGS_S3_PREFIX || env.RECORDINGS_S3_PREFIX || undefined;
|
|
3523
|
+
const region = env.RECORDINGS_S3_REGION || env.AWS_REGION || undefined;
|
|
3524
|
+
return new AudioArtifactStorage({ bucket, prefix, region, client });
|
|
3525
|
+
}
|
|
3526
|
+
async function uploadAudioAtCreation(recordingId, audioPath, storage = resolveAudioArtifactStorage()) {
|
|
3527
|
+
if (!audioPath)
|
|
3528
|
+
return null;
|
|
3529
|
+
if (!storage.usesS3)
|
|
3530
|
+
return null;
|
|
3531
|
+
try {
|
|
3532
|
+
const uploaded = await storage.uploadAudio(recordingId, audioPath);
|
|
3533
|
+
if (!uploaded)
|
|
3534
|
+
return null;
|
|
3535
|
+
console.warn(`recordings: uploaded audio for ${recordingId} to ${uploaded.objectKey}`);
|
|
3536
|
+
return uploaded;
|
|
3537
|
+
} catch (error) {
|
|
3538
|
+
console.warn(`recordings: audio upload failed for ${recordingId} (${String(error instanceof Error ? error.message : error)}); keeping local audio_path only`);
|
|
3539
|
+
return null;
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
function assertSha256(value) {
|
|
3543
|
+
if (!SHA256_HEX.test(value)) {
|
|
3544
|
+
throw new Error("audio digest must be a lowercase hex sha-256; refusing to build a storage key from it");
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
function sha256Hex(bytes) {
|
|
3548
|
+
const hash = createHash("sha256");
|
|
3549
|
+
hash.update(bytes);
|
|
3550
|
+
return hash.digest("hex");
|
|
3551
|
+
}
|
|
3552
|
+
async function readFileBytes(path) {
|
|
3553
|
+
try {
|
|
3554
|
+
return new Uint8Array(await readFile(path));
|
|
3555
|
+
} catch {
|
|
3556
|
+
return null;
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
function extensionOf(path) {
|
|
3560
|
+
const extension = extname(path).replace(/^\./, "").toLowerCase();
|
|
3561
|
+
return extension || "bin";
|
|
3562
|
+
}
|
|
3563
|
+
async function bodyToBytes(body) {
|
|
3564
|
+
if (!body)
|
|
3565
|
+
return new Uint8Array(0);
|
|
3566
|
+
if (body instanceof Uint8Array)
|
|
3567
|
+
return body;
|
|
3568
|
+
if (typeof body.transformToByteArray === "function") {
|
|
3569
|
+
return new Uint8Array(await body.transformToByteArray());
|
|
3570
|
+
}
|
|
3571
|
+
if (typeof body.getReader === "function") {
|
|
3572
|
+
const reader = body.getReader();
|
|
3573
|
+
const chunks2 = [];
|
|
3574
|
+
for (;; ) {
|
|
3575
|
+
const { value, done } = await reader.read();
|
|
3576
|
+
if (done)
|
|
3577
|
+
break;
|
|
3578
|
+
if (value)
|
|
3579
|
+
chunks2.push(value);
|
|
3580
|
+
}
|
|
3581
|
+
return concat(chunks2);
|
|
3582
|
+
}
|
|
3583
|
+
const chunks = [];
|
|
3584
|
+
for await (const chunk of body) {
|
|
3585
|
+
chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
3586
|
+
}
|
|
3587
|
+
return concat(chunks);
|
|
3588
|
+
}
|
|
3589
|
+
function concat(chunks) {
|
|
3590
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
3591
|
+
const merged = new Uint8Array(new ArrayBuffer(total));
|
|
3592
|
+
let offset = 0;
|
|
3593
|
+
for (const chunk of chunks) {
|
|
3594
|
+
merged.set(chunk, offset);
|
|
3595
|
+
offset += chunk.byteLength;
|
|
3596
|
+
}
|
|
3597
|
+
return merged;
|
|
3128
3598
|
}
|
|
3129
3599
|
|
|
3130
3600
|
// src/store.ts
|
|
3131
|
-
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
3132
3601
|
var APP = "recordings";
|
|
3133
3602
|
function listQuery(filter) {
|
|
3134
3603
|
if (!filter)
|
|
@@ -3159,7 +3628,15 @@ var localStore = {
|
|
|
3159
3628
|
mode: "sqlite",
|
|
3160
3629
|
baseUrl: null,
|
|
3161
3630
|
async createRecording(input, idempotencyKey) {
|
|
3162
|
-
return withLocalStoreReaderLease(() =>
|
|
3631
|
+
return withLocalStoreReaderLease(async () => {
|
|
3632
|
+
const recording = createRecording(input, undefined, idempotencyKey);
|
|
3633
|
+
if (!recording.audio_path)
|
|
3634
|
+
return recording;
|
|
3635
|
+
const uploaded = await uploadAudioAtCreation(recording.id, recording.audio_path, localArtifactStorageFor());
|
|
3636
|
+
if (!uploaded)
|
|
3637
|
+
return recording;
|
|
3638
|
+
return updateRecordingAudio(recording.id, uploaded.objectKey, uploaded.sha256, uploaded.bytes) ?? recording;
|
|
3639
|
+
});
|
|
3163
3640
|
},
|
|
3164
3641
|
async getRecording(id) {
|
|
3165
3642
|
return withLocalStoreReaderLease(() => getRecording(id));
|
|
@@ -3353,7 +3830,7 @@ function apiStore(client) {
|
|
|
3353
3830
|
};
|
|
3354
3831
|
}
|
|
3355
3832
|
function recordingPageFingerprint(items) {
|
|
3356
|
-
const hash =
|
|
3833
|
+
const hash = createHash2("sha256");
|
|
3357
3834
|
const ids = items.map((item) => String(item.id ?? "")).sort();
|
|
3358
3835
|
for (const id of ids) {
|
|
3359
3836
|
hash.update(String(id.length));
|
|
@@ -3364,12 +3841,16 @@ function recordingPageFingerprint(items) {
|
|
|
3364
3841
|
return `${items.length}:${hash.digest("hex")}`;
|
|
3365
3842
|
}
|
|
3366
3843
|
var cached = null;
|
|
3367
|
-
|
|
3368
|
-
|
|
3844
|
+
var localArtifactStorageOverride;
|
|
3845
|
+
function localArtifactStorageFor() {
|
|
3846
|
+
return localArtifactStorageOverride ?? resolveAudioArtifactStorage();
|
|
3847
|
+
}
|
|
3848
|
+
function getStore(env = process.env, options = {}) {
|
|
3849
|
+
if (env === process.env && cached && options.credentials === undefined)
|
|
3369
3850
|
return cached;
|
|
3370
|
-
const
|
|
3371
|
-
const store =
|
|
3372
|
-
if (env === process.env)
|
|
3851
|
+
const client = resolveRecordingsCloudClient(env, options);
|
|
3852
|
+
const store = client ? apiStore(client) : localStore;
|
|
3853
|
+
if (env === process.env && options.credentials === undefined)
|
|
3373
3854
|
cached = store;
|
|
3374
3855
|
return store;
|
|
3375
3856
|
}
|
|
@@ -3406,12 +3887,15 @@ class EnhancementError extends Error {
|
|
|
3406
3887
|
}
|
|
3407
3888
|
export {
|
|
3408
3889
|
toV1BaseUrl3 as toV1BaseUrl,
|
|
3409
|
-
|
|
3410
|
-
|
|
3890
|
+
resolveRecordingsTransport,
|
|
3891
|
+
resolveRecordingsCloudClient,
|
|
3892
|
+
isRecordingsLocalOptIn,
|
|
3411
3893
|
getStore,
|
|
3894
|
+
getRecordingsTransportStatus,
|
|
3412
3895
|
createStorageClient,
|
|
3413
3896
|
createHttpTransport,
|
|
3414
3897
|
__resetStore,
|
|
3415
|
-
|
|
3898
|
+
RECORDINGS_LOCAL_OPT_IN_ENV_KEYS,
|
|
3899
|
+
HasnaHttpError2 as HasnaHttpError,
|
|
3416
3900
|
APP
|
|
3417
3901
|
};
|