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