@hasna/recordings 0.3.13 → 0.5.0

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