@hasna/recordings 0.3.13 → 0.4.0

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